diff --git a/README.md b/README.md index d8c43f5..5f1295b 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,28 @@ OpenScribe supports three workflows. **Mixed web mode is the default path.** - Notes: Anthropic Claude (or other hosted LLM) - Requires API keys in `apps/web/.env.local` +### OpenClaw + OpenEMR Demo Handoff (desktop) +- The note editor now includes `Send to OpenClaw` (desktop app path). +- Trigger flow: record encounter -> note appears -> click `Send to OpenClaw`. +- OpenScribe sends patient/note context to OpenClaw and requests an OpenEMR note action. + +Optional environment variables for demos: + +```bash +# OpenClaw CLI (default: openclaw on PATH) +OPENCLAW_BIN=openclaw + +# Target OpenClaw agent/session (default: main) +OPENCLAW_AGENT=main + +# If set to 1, OpenClaw can deliver responses to a configured channel +OPENCLAW_DELIVER=0 + +# Optional webhook transport instead of CLI +# OPENCLAW_DEMO_WEBHOOK_URL=http://127.0.0.1:8787/openscribe/handoff +# OPENCLAW_DEMO_WEBHOOK_TOKEN=your-token +``` + ### FYI Getting API Keys **OpenAI** (transcription): [platform.openai.com/api-keys](https://platform.openai.com/api-keys) - Sign up → API Keys → Create new secret key diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index b6ba12b..caf6f0f 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -447,6 +447,16 @@ function HomePageContent() { const handleStreamError = useCallback((event: MessageEvent | Event) => { const readyState = eventSourceRef.current?.readyState + const hasFinalTranscript = Boolean(finalTranscriptRef.current?.trim()) + const hasActiveSession = Boolean(sessionIdRef.current) + + // EventSource commonly emits a terminal "error" event on normal close. + // Do not mark processing as failed if we already have final transcript or session is closed. + if (hasFinalTranscript || !hasActiveSession) { + debugWarn("Transcription stream closed", { readyState, hasFinalTranscript, hasActiveSession }) + return + } + debugError("Transcription stream error", { event, readyState, apiBaseUrl: apiBaseUrlRef.current }) setTranscriptionStatus("failed") setProcessingMetrics((prev) => ({ diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 2d3838d..7155052 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -32,5 +32,7 @@ export function middleware(request: NextRequest) { } export const config = { - matcher: ["/:path*"], + // Avoid running middleware on API routes so large multipart uploads + // (final audio blobs) are not subject to middleware body limits. + matcher: ["/((?!api).*)"], } diff --git a/apps/web/src/types/desktop.d.ts b/apps/web/src/types/desktop.d.ts index b868758..610d4fa 100644 --- a/apps/web/src/types/desktop.d.ts +++ b/apps/web/src/types/desktop.d.ts @@ -26,6 +26,11 @@ declare global { readEntries: (filter?: unknown) => Promise exportLog: (options: { data: string; filename: string }) => Promise<{ success: boolean; canceled?: boolean; filePath?: string; error?: string }> } + openscribeBackend?: { + invoke: (channel: string, ...args: unknown[]) => Promise + on: (channel: string, listener: (event: unknown, payload: unknown) => void) => void + removeAllListeners: (channel: string) => void + } } interface Window { diff --git a/package.json b/package.json index cd3c3cf..41839dd 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "setup": "node scripts/setup-env.js", "build": "next build apps/web --webpack", "dev": "next dev apps/web --webpack -p 3001", - "dev:desktop": "concurrently -k \"pnpm dev\" \"pnpm electron:dev\"", + "dev:desktop": "concurrently -k \"pnpm dev:local\" \"pnpm electron:dev\"", "electron:dev": "wait-on tcp:3001 && cross-env NODE_ENV=development ELECTRON_START_URL=http://localhost:3001 electron packages/shell/main.js", "medasr:server": ". .venv-med/bin/activate && python scripts/medasr_server.py --port 8001", "whisper:server": "local-only/openscribe-backend/.venv-backend/bin/python scripts/whisper_server.py --port 8002 --model tiny.en --backend cpp --gpu", diff --git a/packages/pipeline/render/src/components/note-editor.tsx b/packages/pipeline/render/src/components/note-editor.tsx index bbf4242..48f3f57 100644 --- a/packages/pipeline/render/src/components/note-editor.tsx +++ b/packages/pipeline/render/src/components/note-editor.tsx @@ -1,12 +1,12 @@ "use client" -import { useState, useEffect } from "react" +import { useState, useEffect, useRef } from "react" import type { Encounter } from "@storage/types" import { Button } from "@ui/lib/ui/button" import { Textarea } from "@ui/lib/ui/textarea" import { Badge } from "@ui/lib/ui/badge" import { ScrollArea } from "@ui/lib/ui/scroll-area" -import { Save, Copy, Download, Check, AlertTriangle } from "lucide-react" +import { Save, Copy, Download, Check, AlertTriangle, Send, X, MessageSquare, Loader2 } from "lucide-react" import { format } from "date-fns" import { cn } from "@ui/lib/utils" @@ -22,6 +22,33 @@ interface NoteEditorProps { } type TabType = "note" | "transcript" +type OpenClawInitState = "idle" | "sending" | "sent" | "failed" + +type OpenClawPayload = { + source: "openscribe" + encounterId: string + patientName: string + patientId: string + visitReason: string + noteMarkdown: string + transcript: string + requestedAction: "openemr_apply_note" +} + +type OpenClawMessage = { + id: string + role: "user" | "assistant" | "system" + text: string + createdAt: string + runId?: string + status?: string +} + +function messageId() { + return typeof crypto !== "undefined" && crypto.randomUUID + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}` +} export function NoteEditor({ encounter, onSave }: NoteEditorProps) { const [activeTab, setActiveTab] = useState("note") @@ -30,11 +57,45 @@ export function NoteEditor({ encounter, onSave }: NoteEditorProps) { const [copied, setCopied] = useState(false) const [saved, setSaved] = useState(false) + const [openClawAvailable, setOpenClawAvailable] = useState(false) + const [openClawPanelOpen, setOpenClawPanelOpen] = useState(false) + const [openClawInitState, setOpenClawInitState] = useState("idle") + const [openClawSessionId, setOpenClawSessionId] = useState("") + const [openClawError, setOpenClawError] = useState("") + const [openClawMessages, setOpenClawMessages] = useState([]) + const [openClawInput, setOpenClawInput] = useState("") + const [openClawSending, setOpenClawSending] = useState(false) + const chatBottomRef = useRef(null) + useEffect(() => { setNoteMarkdown(encounter.note_text || "") setHasChanges(false) + setOpenClawPanelOpen(false) + setOpenClawInitState("idle") + setOpenClawSessionId("") + setOpenClawError("") + setOpenClawMessages([]) + setOpenClawInput("") + setOpenClawSending(false) }, [encounter.id, encounter.note_text]) + useEffect(() => { + if (typeof window === "undefined") return + const desktop = (window as Window & { + desktop?: { + openscribeBackend?: { + invoke: (channel: string, ...args: unknown[]) => Promise + } + } + }).desktop + setOpenClawAvailable(Boolean(desktop?.openscribeBackend)) + }, []) + + useEffect(() => { + if (!openClawPanelOpen) return + chatBottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }) + }, [openClawMessages, openClawPanelOpen, openClawSending]) + const handleNoteChange = (value: string) => { setNoteMarkdown(value) setHasChanges(true) @@ -69,124 +130,417 @@ export function NoteEditor({ encounter, onSave }: NoteEditorProps) { URL.revokeObjectURL(url) } + const appendMessage = (message: OpenClawMessage) => { + setOpenClawMessages((prev) => [...prev, message]) + } + + const sendChatTurn = async (message: string, options?: { isInitial?: boolean }) => { + const desktop = (window as Window & { + desktop?: { + openscribeBackend?: { + invoke: (channel: string, ...args: unknown[]) => Promise + } + } + }).desktop + + if (!desktop?.openscribeBackend) { + setOpenClawError("OpenClaw chat is only available in the desktop app.") + setOpenClawInitState("failed") + appendMessage({ + id: messageId(), + role: "system", + text: "OpenClaw chat is only available in desktop mode.", + createdAt: new Date().toISOString(), + }) + return + } + + if (!options?.isInitial) { + appendMessage({ + id: messageId(), + role: "user", + text: message, + createdAt: new Date().toISOString(), + }) + } + + if (options?.isInitial) { + setOpenClawInitState("sending") + } + setOpenClawSending(true) + setOpenClawError("") + + try { + const result = (await desktop.openscribeBackend.invoke("openclaw-chat-turn", { + encounterId: encounter.id, + patientName: encounter.patient_name || "", + patientId: encounter.patient_id || "", + visitReason: encounter.visit_reason || "", + noteMarkdown, + transcript: encounter.transcript_text || "", + sessionId: openClawSessionId || undefined, + message, + })) as { + success?: boolean + error?: string + sessionId?: string + runId?: string + status?: string + responseText?: string + rawOutput?: string + } + + if (!result?.success) { + const errorMessage = result?.error || "OpenClaw did not accept the request." + if (options?.isInitial) { + setOpenClawInitState("failed") + } + setOpenClawError(errorMessage) + appendMessage({ + id: messageId(), + role: "system", + text: errorMessage, + createdAt: new Date().toISOString(), + status: "error", + }) + return + } + + if (result.sessionId) { + setOpenClawSessionId(result.sessionId) + } + + if (options?.isInitial) { + setOpenClawInitState("sent") + appendMessage({ + id: messageId(), + role: "system", + text: "Clinical note handoff sent to OpenClaw. Continue here to monitor and chat.", + createdAt: new Date().toISOString(), + status: result.status, + }) + } + + appendMessage({ + id: messageId(), + role: "assistant", + text: result.responseText || result.rawOutput || "OpenClaw returned no response text.", + createdAt: new Date().toISOString(), + runId: result.runId, + status: result.status, + }) + } catch (error) { + const messageText = error instanceof Error ? error.message : "OpenClaw chat failed." + if (options?.isInitial) { + setOpenClawInitState("failed") + } + setOpenClawError(messageText) + appendMessage({ + id: messageId(), + role: "system", + text: messageText, + createdAt: new Date().toISOString(), + status: "error", + }) + } finally { + setOpenClawSending(false) + } + } + + const buildInitialHandoffMessage = (): string => { + const payload: OpenClawPayload = { + source: "openscribe", + encounterId: encounter.id, + patientName: encounter.patient_name || "", + patientId: encounter.patient_id || "", + visitReason: encounter.visit_reason || "", + noteMarkdown, + transcript: encounter.transcript_text || "", + requestedAction: "openemr_apply_note", + } + + return [ + "You are receiving a structured handoff from OpenScribe.", + "Primary objective: execute the OpenEMR action for this encounter now.", + "Action target: apply the note into OpenEMR for the current patient chart or create/update the current encounter note.", + "If patient resolution is ambiguous, ask for confirmation before writing data.", + "Return a concise status after action execution.", + "", + `Encounter ID: ${payload.encounterId || "(missing)"}`, + `Patient Name: ${payload.patientName || "(missing)"}`, + `Patient ID: ${payload.patientId || "(missing)"}`, + `Visit Reason: ${payload.visitReason || "(missing)"}`, + `Requested Action: ${payload.requestedAction}`, + "", + "Clinical note markdown:", + payload.noteMarkdown || "(missing)", + "", + "Transcript (optional context):", + payload.transcript || "(missing)", + ].join("\n") + } + + const handleOpenOpenClawChat = async () => { + setOpenClawPanelOpen(true) + + if (!openClawAvailable) { + setOpenClawInitState("failed") + setOpenClawError("OpenClaw handoff is only available in the desktop app.") + if (openClawMessages.length === 0) { + appendMessage({ + id: messageId(), + role: "system", + text: "OpenClaw handoff is only available in desktop mode.", + createdAt: new Date().toISOString(), + status: "error", + }) + } + return + } + + if (openClawMessages.length === 0 && !openClawSending) { + const initialMessage = buildInitialHandoffMessage() + await sendChatTurn(initialMessage, { isInitial: true }) + } + } + + const handleSendUserMessage = async () => { + const text = openClawInput.trim() + if (!text || openClawSending) return + setOpenClawInput("") + await sendChatTurn(text) + } + return ( -
-
-
-
-
-

{encounter.patient_name || "Unknown Patient"}

- {encounter.patient_id && ( - +
+
+
+
+
+

{encounter.patient_name || "Unknown Patient"}

+ {encounter.patient_id && ( + + {encounter.patient_id} + + )} +
+
+ {format(new Date(encounter.created_at), "MMM d, yyyy 'at' h:mm a")} + {encounter.visit_reason && ( + <> + · + {VISIT_TYPE_LABELS[encounter.visit_reason] || encounter.visit_reason} + + )} +
+
+
+ +
+
+ + +
+ +
+ + + {activeTab === "note" && ( + )} -
-
- {format(new Date(encounter.created_at), "MMM d, yyyy 'at' h:mm a")} - {encounter.visit_reason && ( - <> - · - {VISIT_TYPE_LABELS[encounter.visit_reason] || encounter.visit_reason} - + {activeTab === "note" && ( + )}
- {/* Tabs with action buttons */} -
-
- - -
- -
- - - {activeTab === "note" && ( -