| 1 | "use client"; |
| 2 | |
| 3 | import { useState, useRef, useEffect, useCallback } from "react"; |
| 4 | import type { AskResponse, Receipt } from "@/lib/types"; |
| 5 | import { renderMarkdownLite } from "@/lib/markdown-lite"; |
| 6 | |
| 7 | function stripReceiptMd(s: string): string { |
| 8 | return s |
| 9 | .replace(/```[\s\S]*?```/g, " ") |
| 10 | .replace(/`([^`]*)`/g, "$1") |
| 11 | .replace(/^#{1,6}\s*/gm, "") |
| 12 | .replace(/^\s*[-*+]\s+/gm, "· ") |
| 13 | .replace(/\*\*(.*?)\*\*/g, "$1") |
| 14 | .replace(/\*(.*?)\*/g, "$1") |
| 15 | .replace(/\s+/g, " ") |
| 16 | .trim(); |
| 17 | } |
| 18 | |
| 19 | interface AskBoxProps { |
| 20 | data: AskResponse | null; |
| 21 | loading: boolean; |
| 22 | error: string | null; |
| 23 | onSubmit: (query: string) => Promise<void>; |
| 24 | onFocusChange?: (focused: boolean) => void; |
| 25 | } |
| 26 | |
| 27 | function ReceiptChip({ receipt, isExpanded, onToggle }: { |
| 28 | receipt: Receipt; |
| 29 | isExpanded: boolean; |
| 30 | onToggle: () => void; |
| 31 | }) { |
| 32 | const confidencePct = Math.round(receipt.confidence * 100); |
| 33 | |
| 34 | return ( |
| 35 | <button |
| 36 | onClick={onToggle} |
| 37 | data-cursor-expand |
| 38 | className="group text-left w-full" |
| 39 | aria-expanded={isExpanded} |
| 40 | > |
| 41 | <div className="border-l-2 border-teal-bio pl-3 py-1.5 bg-surface-1 hover:bg-surface-2 transition-colors duration-200 rounded-r-sm"> |
| 42 | <div className="flex items-center gap-2 flex-wrap"> |
| 43 | <span className="font-mono text-label-sm text-teal-bio uppercase tracking-widest"> |
| 44 | [{receipt.source}] |
| 45 | </span> |
| 46 | <span className="font-mono text-label-sm text-text-muted truncate max-w-[200px]"> |
| 47 | {receipt.path} |
| 48 | </span> |
| 49 | <span className="font-mono text-label-sm text-text-secondary ml-auto flex-shrink-0"> |
| 50 | {confidencePct}% |
| 51 | </span> |
| 52 | </div> |
| 53 | |
| 54 | {isExpanded && ( |
| 55 | <div className="mt-2 pt-2 border-t border-surface-3"> |
| 56 | <p className="font-ui text-xs text-text-secondary leading-relaxed"> |
| 57 | {stripReceiptMd(receipt.snippet)} |
| 58 | </p> |
| 59 | <p className="font-mono text-label-sm text-text-muted mt-1"> |
| 60 | {new Date(receipt.last_seen).toLocaleString()} |
| 61 | </p> |
| 62 | </div> |
| 63 | )} |
| 64 | </div> |
| 65 | </button> |
| 66 | ); |
| 67 | } |
| 68 | |
| 69 | export default function AskBox({ data, loading, error, onSubmit, onFocusChange }: AskBoxProps) { |
| 70 | const [query, setQuery] = useState(""); |
| 71 | const [expandedReceipts, setExpandedReceipts] = useState<Set<string>>(new Set()); |
| 72 | const inputRef = useRef<HTMLInputElement>(null); |
| 73 | const answerRef = useRef<HTMLDivElement>(null); |
| 74 | |
| 75 | const handleSubmit = useCallback( |
| 76 | async (e: React.FormEvent) => { |
| 77 | e.preventDefault(); |
| 78 | if (!query.trim() || loading) return; |
| 79 | await onSubmit(query); |
| 80 | }, |
| 81 | [query, loading, onSubmit] |
| 82 | ); |
| 83 | |
| 84 | const toggleReceipt = useCallback((id: string) => { |
| 85 | setExpandedReceipts((prev) => { |
| 86 | const next = new Set(prev); |
| 87 | if (next.has(id)) next.delete(id); |
| 88 | else next.add(id); |
| 89 | return next; |
| 90 | }); |
| 91 | }, []); |
| 92 | |
| 93 | useEffect(() => { |
| 94 | if (!data || !answerRef.current) return; |
| 95 | const el = answerRef.current; |
| 96 | el.style.opacity = "0"; |
| 97 | el.style.transform = "translateY(20px)"; |
| 98 | requestAnimationFrame(() => { |
| 99 | requestAnimationFrame(() => { |
| 100 | el.style.transition = "opacity 0.7s cubic-bezier(0.16,1,0.3,1), transform 0.7s cubic-bezier(0.16,1,0.3,1)"; |
| 101 | el.style.opacity = "1"; |
| 102 | el.style.transform = "translateY(0)"; |
| 103 | }); |
| 104 | }); |
| 105 | }, [data]); |
| 106 | |
| 107 | return ( |
| 108 | <div className="flex flex-col gap-7 h-full"> |
| 109 | {} |
| 110 | <div> |
| 111 | <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest mb-3"> |
| 112 | Ask Darwin |
| 113 | </p> |
| 114 | <h2 className="font-display text-display-md font-bold text-text-primary leading-tight"> |
| 115 | Query the organism |
| 116 | </h2> |
| 117 | </div> |
| 118 | |
| 119 | {} |
| 120 | <form onSubmit={handleSubmit} className="relative"> |
| 121 | <label htmlFor="darwin-ask" className="sr-only"> |
| 122 | Ask Darwin a question about your homelab |
| 123 | </label> |
| 124 | <input |
| 125 | ref={inputRef} |
| 126 | id="darwin-ask" |
| 127 | type="text" |
| 128 | value={query} |
| 129 | onChange={(e) => setQuery(e.target.value)} |
| 130 | placeholder="What is Hermes doing right now?" |
| 131 | autoComplete="off" |
| 132 | autoCorrect="off" |
| 133 | autoCapitalize="off" |
| 134 | spellCheck="false" |
| 135 | disabled={loading} |
| 136 | onFocus={() => onFocusChange?.(true)} |
| 137 | onBlur={() => onFocusChange?.(false)} |
| 138 | style={{ fontSize: "16px", minHeight: "44px" }} |
| 139 | className={[ |
| 140 | "w-full bg-transparent border-b-2 border-text-muted", |
| 141 | "focus:border-teal-bio outline-none", |
| 142 | "font-ui text-text-primary placeholder:text-text-muted", |
| 143 | "py-3 pr-16 transition-colors duration-300", |
| 144 | "disabled:opacity-50", |
| 145 | ].join(" ")} |
| 146 | /> |
| 147 | <button |
| 148 | type="submit" |
| 149 | disabled={loading || !query.trim()} |
| 150 | aria-label="Submit query" |
| 151 | style={{ minWidth: "44px", minHeight: "44px", touchAction: "manipulation" }} |
| 152 | className={[ |
| 153 | "absolute right-0 top-1/2 -translate-y-1/2", |
| 154 | "w-11 h-11 flex items-center justify-center", |
| 155 | "border border-text-muted hover:border-teal-bio", |
| 156 | "text-text-muted hover:text-teal-bio", |
| 157 | "transition-all duration-200 disabled:opacity-30", |
| 158 | ].join(" ")} |
| 159 | data-cursor-expand |
| 160 | > |
| 161 | {loading ? ( |
| 162 | <svg className="animate-spin" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"> |
| 163 | <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="2" strokeDasharray="20 17" /> |
| 164 | </svg> |
| 165 | ) : ( |
| 166 | <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"> |
| 167 | <path d="M2 8h12M8 2l6 6-6 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="square" /> |
| 168 | </svg> |
| 169 | )} |
| 170 | </button> |
| 171 | </form> |
| 172 | |
| 173 | {} |
| 174 | {loading && ( |
| 175 | <div className="flex items-center gap-3"> |
| 176 | <div className="flex gap-1" aria-hidden="true"> |
| 177 | {[0, 1, 2].map((i) => ( |
| 178 | <div |
| 179 | key={i} |
| 180 | className="w-1.5 h-1.5 rounded-full bg-teal-bio" |
| 181 | style={{ |
| 182 | animation: `pulse 1.2s ease-in-out ${i * 0.2}s infinite`, |
| 183 | }} |
| 184 | /> |
| 185 | ))} |
| 186 | </div> |
| 187 | <span className="font-mono text-label-sm text-text-muted" aria-live="polite"> |
| 188 | Querying all engines... |
| 189 | </span> |
| 190 | </div> |
| 191 | )} |
| 192 | |
| 193 | {} |
| 194 | {error && ( |
| 195 | <div className="border-l-2 border-red-500 pl-3 py-2" role="alert"> |
| 196 | <p className="font-mono text-label-sm text-red-400">{error}</p> |
| 197 | </div> |
| 198 | )} |
| 199 | |
| 200 | {} |
| 201 | {data && ( |
| 202 | <div ref={answerRef} className="flex flex-col gap-6 overflow-y-auto w-full max-w-full"> |
| 203 | {} |
| 204 | {data.answer && ( |
| 205 | <div className="w-full max-w-full"> |
| 206 | <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest mb-4"> |
| 207 | Answer |
| 208 | </p> |
| 209 | <div |
| 210 | className="font-ui text-base text-text-primary answer-prose" |
| 211 | style={{ lineHeight: "1.75", letterSpacing: "0.005em" }} |
| 212 | dangerouslySetInnerHTML={{ __html: renderMarkdownLite(data.answer) }} |
| 213 | /> |
| 214 | </div> |
| 215 | )} |
| 216 | |
| 217 | {} |
| 218 | <div className="flex items-center gap-2 flex-wrap pt-2 border-t border-surface-2"> |
| 219 | {data.engines.map((eng) => ( |
| 220 | <span |
| 221 | key={eng.engine} |
| 222 | className={[ |
| 223 | "font-mono text-label-sm px-3 py-1 border rounded-sm", |
| 224 | eng.status === "up" |
| 225 | ? "border-teal-muted text-teal-bio" |
| 226 | : "border-surface-3 text-text-muted", |
| 227 | ].join(" ")} |
| 228 | > |
| 229 | {eng.engine} |
| 230 | {eng.ms != null && <span className="opacity-50 ml-1.5">{eng.ms}ms</span>} |
| 231 | </span> |
| 232 | ))} |
| 233 | <span className="font-mono text-label-sm text-text-muted ml-auto opacity-60"> |
| 234 | {data.took_ms}ms total |
| 235 | </span> |
| 236 | </div> |
| 237 | |
| 238 | {} |
| 239 | {data.receipts.length > 0 && ( |
| 240 | <div> |
| 241 | <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest mb-3"> |
| 242 | Sources - {data.receipts.length} |
| 243 | </p> |
| 244 | <div className="flex flex-col gap-2.5"> |
| 245 | {data.receipts.map((receipt) => ( |
| 246 | <ReceiptChip |
| 247 | key={receipt.id} |
| 248 | receipt={receipt} |
| 249 | isExpanded={expandedReceipts.has(receipt.id)} |
| 250 | onToggle={() => toggleReceipt(receipt.id)} |
| 251 | /> |
| 252 | ))} |
| 253 | </div> |
| 254 | </div> |
| 255 | )} |
| 256 | |
| 257 | {} |
| 258 | {data.conflicts.length > 0 && ( |
| 259 | <div> |
| 260 | <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest mb-2"> |
| 261 | Conflicts |
| 262 | </p> |
| 263 | {data.conflicts.map((c, i) => ( |
| 264 | <div key={i} className="border-l-2 border-yellow-600 pl-3 py-1.5 mb-2"> |
| 265 | <p className="font-mono text-label-sm text-yellow-500">{c.claim}</p> |
| 266 | <p className="font-mono text-label-sm text-text-muted"> |
| 267 | Winner: {c.winner} - {c.reason} |
| 268 | </p> |
| 269 | </div> |
| 270 | ))} |
| 271 | </div> |
| 272 | )} |
| 273 | </div> |
| 274 | )} |
| 275 | |
| 276 | {} |
| 277 | {!data && !loading && !error && ( |
| 278 | <div className="flex-1 flex items-center justify-center"> |
| 279 | <div className="text-center"> |
| 280 | <div className="w-12 h-12 mx-auto mb-4 border border-surface-3 flex items-center justify-center" aria-hidden="true"> |
| 281 | <svg width="20" height="20" viewBox="0 0 20 20" fill="none"> |
| 282 | <circle cx="10" cy="10" r="8" stroke="#16e0c8" strokeWidth="1" opacity="0.4" /> |
| 283 | <circle cx="10" cy="10" r="3" fill="#16e0c8" opacity="0.6" /> |
| 284 | </svg> |
| 285 | </div> |
| 286 | <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest"> |
| 287 | Organism ready |
| 288 | </p> |
| 289 | </div> |
| 290 | </div> |
| 291 | )} |
| 292 | </div> |
| 293 | ); |
| 294 | } |