@fayz-ai/plugin-scribe 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/components/DraftPanel.d.ts +3 -1
  2. package/dist/components/DraftPanel.d.ts.map +1 -1
  3. package/dist/components/ScribeAssistantBridge.d.ts +8 -0
  4. package/dist/components/ScribeAssistantBridge.d.ts.map +1 -0
  5. package/dist/components/ScribeDevicePicker.d.ts +7 -0
  6. package/dist/components/ScribeDevicePicker.d.ts.map +1 -0
  7. package/dist/components/ScribeHeaderAction.d.ts +15 -0
  8. package/dist/components/ScribeHeaderAction.d.ts.map +1 -0
  9. package/dist/components/ScribeLivePanel.d.ts +8 -0
  10. package/dist/components/ScribeLivePanel.d.ts.map +1 -0
  11. package/dist/components/ScribeRecordingPill.d.ts +9 -1
  12. package/dist/components/ScribeRecordingPill.d.ts.map +1 -1
  13. package/dist/components/ScribeRecoveryBanner.d.ts.map +1 -1
  14. package/dist/components/ScribeSessionPage.d.ts +5 -1
  15. package/dist/components/ScribeSessionPage.d.ts.map +1 -1
  16. package/dist/components/ScribeShellMount.d.ts +4 -3
  17. package/dist/components/ScribeShellMount.d.ts.map +1 -1
  18. package/dist/components/TranscriptPanel.d.ts +1 -1
  19. package/dist/components/TranscriptPanel.d.ts.map +1 -1
  20. package/dist/data/supabase.d.ts +4 -12
  21. package/dist/data/supabase.d.ts.map +1 -1
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1203 -433
  25. package/dist/index.js.map +1 -1
  26. package/dist/lib/drain.d.ts.map +1 -1
  27. package/dist/lib/generate.d.ts +3 -3
  28. package/dist/lib/generate.d.ts.map +1 -1
  29. package/dist/lib/live-transcript.d.ts +13 -0
  30. package/dist/lib/live-transcript.d.ts.map +1 -0
  31. package/dist/lib/timeline-source.d.ts +9 -0
  32. package/dist/lib/timeline-source.d.ts.map +1 -0
  33. package/dist/lib/transcript-clean.d.ts +4 -0
  34. package/dist/lib/transcript-clean.d.ts.map +1 -0
  35. package/dist/lib/transcript-clean.test.d.ts +2 -0
  36. package/dist/lib/transcript-clean.test.d.ts.map +1 -0
  37. package/dist/runtime/capture.d.ts +19 -0
  38. package/dist/runtime/capture.d.ts.map +1 -1
  39. package/dist/runtime/idb.d.ts +3 -0
  40. package/dist/runtime/idb.d.ts.map +1 -1
  41. package/dist/runtime/index.d.ts +9 -0
  42. package/dist/runtime/index.d.ts.map +1 -1
  43. package/dist/runtime/pump.d.ts.map +1 -1
  44. package/dist/types.d.ts +14 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/package.json +5 -5
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
- import * as React6 from 'react';
2
- import React6__default from 'react';
1
+ import * as React7 from 'react';
2
+ import React7__default from 'react';
3
3
  import { getActiveTenantId, getSupabaseClientOptional, registerTranslations } from '@fayz-ai/core';
4
4
  import { create } from 'zustand';
5
5
  import { createPortal } from 'react-dom';
6
- import { useAgentSurface, useAuth, resolveFayzAgentConnection } from '@fayz-ai/admin';
7
- import { Button, toast, Badge, Tabs, TabsList, TabsTrigger, TabsContent, Card, CardContent, MarkdownEditor, Modal, ModalContent, ModalHeader, ModalTitle, ModalBody, Checkbox, ModalFooter } from '@fayz-ai/ui/primitives';
8
- import { Mic, Loader2, FileText, Sparkles, AlertTriangle, RefreshCw, Trash2, Check, Pause, Play, Square, ShieldCheck } from 'lucide-react';
6
+ import { useAgentSurface, registerTimelineSource, useAuth, useRightRailStore, useAccessOptional, openAssistant, usePublishAssistantActivity, useRegisterAssistantLauncher, resolveFayzAgentConnection } from '@fayz-ai/admin';
7
+ import { Button, toast, Skeleton, Badge, Tabs, TabsList, TabsTrigger, TabsContent, Card, CardContent, Modal, ModalContent, ModalHeader, ModalTitle, ModalBody, Checkbox, ModalFooter, MarkdownEditor } from '@fayz-ai/ui/primitives';
8
+ import { Mic, FileText, Sparkles, Loader2, Pause, Play, Square, Settings2, AlertTriangle, RefreshCw, Trash2, Check, ShieldCheck } from 'lucide-react';
9
9
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
10
10
  import { SubpageHeader } from '@fayz-ai/ui/layout';
11
11
 
@@ -234,6 +234,60 @@ function isCaptureSupported() {
234
234
  var live = null;
235
235
  var listeners = /* @__PURE__ */ new Set();
236
236
  var lastError;
237
+ var DEVICE_KEY = "fayz.scribe.inputDevice";
238
+ function getPreferredDeviceId() {
239
+ try {
240
+ return localStorage.getItem(DEVICE_KEY);
241
+ } catch {
242
+ return null;
243
+ }
244
+ }
245
+ function setPreferredDeviceId(deviceId) {
246
+ try {
247
+ if (deviceId) localStorage.setItem(DEVICE_KEY, deviceId);
248
+ else localStorage.removeItem(DEVICE_KEY);
249
+ } catch {
250
+ }
251
+ }
252
+ var activeDeviceId = null;
253
+ function getActiveDeviceId() {
254
+ return activeDeviceId ?? getPreferredDeviceId();
255
+ }
256
+ function audioConstraints() {
257
+ const deviceId = getActiveDeviceId();
258
+ return {
259
+ channelCount: 1,
260
+ echoCancellation: true,
261
+ noiseSuppression: true,
262
+ autoGainControl: true,
263
+ sampleRate: 16e3,
264
+ // `exact` would be worse: a remembered device may be unplugged, and then
265
+ // recording would not start at all instead of falling back.
266
+ ...deviceId ? { deviceId: { ideal: deviceId } } : {}
267
+ };
268
+ }
269
+ async function switchInputDevice(deviceId) {
270
+ activeDeviceId = deviceId;
271
+ if (!live) return true;
272
+ const cap = live;
273
+ try {
274
+ const next = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints() });
275
+ const wasRecording = cap.session.state === "recording";
276
+ if (wasRecording) await pauseCapture();
277
+ cap.stream.getTracks().forEach((track) => track.stop());
278
+ cap.stream = next;
279
+ cap.meter?.stop();
280
+ cap.meter = attachLevelMeter(next);
281
+ if (wasRecording) await resumeCapture();
282
+ emit();
283
+ return true;
284
+ } catch {
285
+ return false;
286
+ }
287
+ }
288
+ function getCaptureStream() {
289
+ return live?.stream ?? null;
290
+ }
237
291
  function getSnapshot() {
238
292
  if (!live) return { state: "idle", sessionId: null, elapsedMs: 0, segIndex: 0, error: lastError };
239
293
  return {
@@ -264,12 +318,48 @@ function elapsedMs(session) {
264
318
  const running2 = session.state === "recording" && session.resumedAtEpochMs !== null;
265
319
  return session.accumulatedMs + (running2 ? Date.now() - session.resumedAtEpochMs : 0);
266
320
  }
321
+ var SILENCE_PEAK = 0.02;
322
+ function attachLevelMeter(stream) {
323
+ const AudioCtor = typeof window === "undefined" ? void 0 : window.AudioContext ?? window.webkitAudioContext;
324
+ if (!AudioCtor) return null;
325
+ try {
326
+ const context = new AudioCtor();
327
+ const analyser = context.createAnalyser();
328
+ analyser.fftSize = 512;
329
+ context.createMediaStreamSource(stream).connect(analyser);
330
+ const buffer = new Uint8Array(analyser.frequencyBinCount);
331
+ let peak = 0;
332
+ const timer3 = setInterval(() => {
333
+ analyser.getByteFrequencyData(buffer);
334
+ let sum = 0;
335
+ for (const v of buffer) sum += v;
336
+ const level = sum / buffer.length / 255;
337
+ if (level > peak) peak = level;
338
+ }, 100);
339
+ return {
340
+ context,
341
+ stop: () => {
342
+ clearInterval(timer3);
343
+ void context.close().catch(() => {
344
+ });
345
+ },
346
+ peak: () => peak,
347
+ reset: () => {
348
+ peak = 0;
349
+ }
350
+ };
351
+ } catch {
352
+ return null;
353
+ }
354
+ }
267
355
  async function closeSegment(cap, partial) {
268
356
  const { session, buffer, segStartOffsetMs } = cap;
269
357
  if (buffer.length === 0) return;
270
358
  const segIndex = session.segIndex;
271
359
  const blob = new Blob(buffer, { type: session.mimeType });
272
360
  const nominalDurationMs = buffer.length * PIECE_MS;
361
+ const silent = cap.meter ? cap.meter.peak() < SILENCE_PEAK : false;
362
+ cap.meter?.reset();
273
363
  await putSegment({
274
364
  sessionId: session.id,
275
365
  segIndex,
@@ -279,6 +369,7 @@ async function closeSegment(cap, partial) {
279
369
  nominalDurationMs,
280
370
  partial,
281
371
  gap: false,
372
+ silent,
282
373
  uploadState: "pending",
283
374
  attempts: 0,
284
375
  nextAttemptAt: 0,
@@ -444,13 +535,7 @@ async function startCapture(opts) {
444
535
  let stream;
445
536
  try {
446
537
  stream = await navigator.mediaDevices.getUserMedia({
447
- audio: {
448
- channelCount: 1,
449
- echoCancellation: true,
450
- noiseSuppression: true,
451
- autoGainControl: true,
452
- sampleRate: 16e3
453
- }
538
+ audio: audioConstraints()
454
539
  });
455
540
  } catch (err) {
456
541
  const name = err?.name;
@@ -489,7 +574,8 @@ async function startCapture(opts) {
489
574
  heartbeat: null,
490
575
  rotateTimer: null,
491
576
  rotating: false,
492
- interruptedAtMs: null
577
+ interruptedAtMs: null,
578
+ meter: attachLevelMeter(stream)
493
579
  };
494
580
  live = cap;
495
581
  lastError = void 0;
@@ -501,6 +587,8 @@ async function startCapture(opts) {
501
587
  cap.heartbeat = setInterval(() => {
502
588
  if (!live) return;
503
589
  live.session.lastTickEpochMs = Date.now();
590
+ live.session.accumulatedMs = elapsedMs(live.session);
591
+ live.session.resumedAtEpochMs = Date.now();
504
592
  void putSession(live.session);
505
593
  emit();
506
594
  }, HEARTBEAT_MS);
@@ -536,9 +624,9 @@ async function resumeCapture() {
536
624
  const track = cap.stream.getAudioTracks()[0];
537
625
  if (!track || track.readyState === "ended") {
538
626
  try {
539
- cap.stream = await navigator.mediaDevices.getUserMedia({
540
- audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true }
541
- });
627
+ cap.stream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints() });
628
+ cap.meter?.stop();
629
+ cap.meter = attachLevelMeter(cap.stream);
542
630
  watchTrack(cap);
543
631
  } catch {
544
632
  lastError = "n\xE3o foi poss\xEDvel reabrir o microfone";
@@ -588,6 +676,7 @@ async function stopCapture() {
588
676
  finished.state = "idle";
589
677
  await putSession(finished);
590
678
  }
679
+ live?.meter?.stop();
591
680
  live = null;
592
681
  emit();
593
682
  return sessionId;
@@ -606,11 +695,14 @@ async function adoptOrphan(session) {
606
695
  heartbeat: null,
607
696
  rotateTimer: null,
608
697
  rotating: false,
609
- interruptedAtMs: session.lastTickEpochMs
698
+ interruptedAtMs: session.lastTickEpochMs,
699
+ // An adopted orphan has no open mic — nothing to measure.
700
+ meter: null
610
701
  };
611
- if (pieces.length > 0) await closeSegment(live, true);
612
- live.session.state = "interrupted";
613
- await putSession(live.session);
702
+ const adopted = live;
703
+ if (pieces.length > 0) await closeSegment(adopted, true);
704
+ adopted.session.state = "interrupted";
705
+ await putSession(adopted.session);
614
706
  lastError = "grava\xE7\xE3o interrompida";
615
707
  emit();
616
708
  }
@@ -621,6 +713,7 @@ async function releaseCapture() {
621
713
  for (const track of live.stream.getTracks()) track.stop();
622
714
  await releaseWakeLock(live);
623
715
  detachLifecycle();
716
+ live?.meter?.stop();
624
717
  live = null;
625
718
  lastError = void 0;
626
719
  emit();
@@ -731,6 +824,9 @@ async function uploadOne(segment) {
731
824
  });
732
825
  if (error && !isDuplicateError(error)) throw error;
733
826
  await recordSegment(supabase, session.id, segment, path);
827
+ if (segment.silent) {
828
+ await supabase.from(T.segments).update({ stt_state: "skipped" }).eq("session_id", session.id).eq("seg_index", segment.segIndex);
829
+ }
734
830
  await putSegment({ ...segment, blob: null, uploadState: "uploaded", storagePath: path });
735
831
  } catch (err) {
736
832
  const attempts = segment.attempts + 1;
@@ -819,6 +915,11 @@ var retention = {
819
915
  requireConsent: true,
820
916
  defaultConsentMode: "verbal"
821
917
  };
918
+ var TRIVIAL_ORPHAN_MS = 5e3;
919
+ function orphanDurationMs(session) {
920
+ const wall = Math.max(0, session.lastTickEpochMs - session.startedAtEpochMs);
921
+ return Math.max(session.accumulatedMs, wall);
922
+ }
822
923
  var orphans = [];
823
924
  var orphanListeners = /* @__PURE__ */ new Set();
824
925
  function subscribeOrphans(fn) {
@@ -842,7 +943,15 @@ function initScribeRuntime(options) {
842
943
  startPump();
843
944
  void (async () => {
844
945
  try {
845
- orphans = await findOrphanSessions();
946
+ const found = await findOrphanSessions();
947
+ const worthless = found.filter((s) => orphanDurationMs(s) < TRIVIAL_ORPHAN_MS);
948
+ for (const session of worthless) {
949
+ try {
950
+ await discardSession(session.id);
951
+ } catch {
952
+ }
953
+ }
954
+ orphans = found.filter((s) => orphanDurationMs(s) >= TRIVIAL_ORPHAN_MS);
846
955
  if (orphans.length > 0) {
847
956
  const latest = orphans.reduce((a, b) => a.lastTickEpochMs > b.lastTickEpochMs ? a : b);
848
957
  await adoptOrphan(latest);
@@ -974,10 +1083,10 @@ function formatElapsed(ms) {
974
1083
  }
975
1084
  var MOBILE_BREAKPOINT = 768;
976
1085
  function useIsMobile() {
977
- const [isMobile, setIsMobile] = React6.useState(
1086
+ const [isMobile, setIsMobile] = React7.useState(
978
1087
  () => typeof window !== "undefined" && window.innerWidth < MOBILE_BREAKPOINT
979
1088
  );
980
- React6.useEffect(() => {
1089
+ React7.useEffect(() => {
981
1090
  const mq = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
982
1091
  const onChange = (e) => setIsMobile(e.matches);
983
1092
  setIsMobile(mq.matches);
@@ -987,8 +1096,8 @@ function useIsMobile() {
987
1096
  return isMobile;
988
1097
  }
989
1098
  function useTicker(active) {
990
- const [, force] = React6.useReducer((n) => n + 1, 0);
991
- React6.useEffect(() => {
1099
+ const [, force] = React7.useReducer((n) => n + 1, 0);
1100
+ React7.useEffect(() => {
992
1101
  if (!active) return;
993
1102
  let raf = 0;
994
1103
  let last = 0;
@@ -1009,16 +1118,16 @@ function useTicker(active) {
1009
1118
  }, [active]);
1010
1119
  return 0;
1011
1120
  }
1012
- function ScribeRecordingPill({ labels, onOpenSession }) {
1121
+ function ScribeRecordingPill({ labels, onOpenSession, mobileOnly }) {
1013
1122
  const state = useScribeStore((s) => s.state);
1014
1123
  const sessionId = useScribeStore((s) => s.sessionId);
1015
1124
  const subjectName = useScribeStore((s) => s.subjectName);
1016
1125
  const isMobile = useIsMobile();
1017
- const [busy, setBusy] = React6.useState(false);
1126
+ const [busy, setBusy] = React7.useState(false);
1018
1127
  const active = state === "recording" || state === "paused" || state === "interrupted";
1019
1128
  useTicker(state === "recording");
1020
- const elapsedMs2 = active ? useScribeStore.getState().elapsedMs : 0;
1021
- const handleToggle = React6.useCallback(async () => {
1129
+ const elapsedMs2 = active ? getSnapshot().elapsedMs : 0;
1130
+ const handleToggle = React7.useCallback(async () => {
1022
1131
  setBusy(true);
1023
1132
  try {
1024
1133
  if (state === "recording") await pauseSession();
@@ -1027,7 +1136,7 @@ function ScribeRecordingPill({ labels, onOpenSession }) {
1027
1136
  setBusy(false);
1028
1137
  }
1029
1138
  }, [state]);
1030
- const handleStop = React6.useCallback(async () => {
1139
+ const handleStop = React7.useCallback(async () => {
1031
1140
  setBusy(true);
1032
1141
  try {
1033
1142
  const id = await endSession();
@@ -1037,6 +1146,7 @@ function ScribeRecordingPill({ labels, onOpenSession }) {
1037
1146
  }
1038
1147
  }, [onOpenSession]);
1039
1148
  if (!active || !sessionId) return null;
1149
+ if (mobileOnly && !isMobile) return null;
1040
1150
  const tone = state === "recording" ? "bg-destructive/10 text-destructive border-destructive/30" : state === "interrupted" ? "bg-amber-500/10 text-amber-700 border-amber-500/30 dark:text-amber-400" : "bg-muted text-muted-foreground border-border";
1041
1151
  const content = /* @__PURE__ */ jsxs(
1042
1152
  "div",
@@ -1114,12 +1224,15 @@ function ScribeStartButton({ labels, disabled, onStart, variant = "default", siz
1114
1224
  var RESUMABLE_WINDOW_MS = 30 * 60 * 1e3;
1115
1225
  function ScribeRecoveryBanner({ labels, onOpenSession }) {
1116
1226
  const orphans2 = useScribeStore((s) => s.orphans);
1117
- const [busy, setBusy] = React6.useState(null);
1227
+ const state = useScribeStore((s) => s.state);
1228
+ const [busy, setBusy] = React7.useState(null);
1229
+ const live2 = state === "recording" || state === "paused" || state === "requesting";
1230
+ if (live2) return null;
1118
1231
  if (orphans2.length === 0) return null;
1119
1232
  const session = orphans2[0];
1120
1233
  const interruptedAgoMs = Date.now() - session.lastTickEpochMs;
1121
1234
  const canResume = interruptedAgoMs < RESUMABLE_WINDOW_MS;
1122
- const durationLabel = formatElapsed(session.accumulatedMs);
1235
+ const durationLabel = formatElapsed(orphanDurationMs(session));
1123
1236
  const run = async (fn) => {
1124
1237
  setBusy(session.id);
1125
1238
  try {
@@ -1180,8 +1293,8 @@ function ScribeConsentDialog({
1180
1293
  onCancel,
1181
1294
  onConfirm
1182
1295
  }) {
1183
- const [acknowledged, setAcknowledged] = React6.useState(false);
1184
- React6.useEffect(() => {
1296
+ const [acknowledged, setAcknowledged] = React7.useState(false);
1297
+ React7.useEffect(() => {
1185
1298
  if (open) setAcknowledged(false);
1186
1299
  }, [open]);
1187
1300
  return /* @__PURE__ */ jsx(Modal, { open, onOpenChange: (next) => !next && onCancel(), children: /* @__PURE__ */ jsxs(ModalContent, { size: "sm", children: [
@@ -1214,231 +1327,6 @@ function ScribeConsentDialog({
1214
1327
  ] })
1215
1328
  ] }) });
1216
1329
  }
1217
- var SCRIBE_START_EVENT = "scribe:start";
1218
- function ScribeShellMount({ labels, retention: retention2, locale, onOpenSession }) {
1219
- const { user } = useAuth();
1220
- const [pending, setPending] = React6.useState(null);
1221
- React6.useEffect(() => {
1222
- wireScribeStore();
1223
- }, []);
1224
- React6.useEffect(() => {
1225
- const onStart = (ev) => {
1226
- const detail = ev.detail ?? {};
1227
- if (retention2.requireConsent) setPending(detail);
1228
- else void start(detail, retention2.defaultConsentMode);
1229
- };
1230
- window.addEventListener(SCRIBE_START_EVENT, onStart);
1231
- return () => window.removeEventListener(SCRIBE_START_EVENT, onStart);
1232
- }, [retention2.requireConsent, retention2.defaultConsentMode, user?.id, locale]);
1233
- const start = React6.useCallback(
1234
- async (detail, consentMode) => {
1235
- if (!user?.id) {
1236
- toast.error("Fa\xE7a login para gravar um atendimento");
1237
- return;
1238
- }
1239
- const result = await beginSession({
1240
- userId: user.id,
1241
- subjectId: detail.subjectId,
1242
- subjectName: detail.subjectName,
1243
- appointmentId: detail.appointmentId,
1244
- templateKeys: detail.templateKeys,
1245
- locale,
1246
- consentMode
1247
- });
1248
- if (!result.ok) {
1249
- toast.error(`N\xE3o foi poss\xEDvel iniciar: ${result.reason}`);
1250
- return;
1251
- }
1252
- toast.success(`${labels.sessionSingular} iniciado`);
1253
- },
1254
- [user?.id, locale, labels.sessionSingular]
1255
- );
1256
- return /* @__PURE__ */ jsxs(Fragment, { children: [
1257
- /* @__PURE__ */ jsx(ScribeRecordingPill, { labels, onOpenSession }),
1258
- /* @__PURE__ */ jsx(
1259
- ScribeConsentDialog,
1260
- {
1261
- open: pending !== null,
1262
- labels,
1263
- subjectName: pending?.subjectName,
1264
- defaultConsentMode: retention2.defaultConsentMode,
1265
- onCancel: () => setPending(null),
1266
- onConfirm: (mode) => {
1267
- const detail = pending;
1268
- setPending(null);
1269
- if (detail) void start(detail, mode);
1270
- }
1271
- }
1272
- ),
1273
- typeof document !== "undefined" && createPortal(
1274
- /* @__PURE__ */ jsx("div", { className: "pointer-events-auto fixed inset-x-0 top-14 z-40", children: /* @__PURE__ */ jsx(ScribeRecoveryBanner, { labels, onOpenSession }) }),
1275
- document.body
1276
- )
1277
- ] });
1278
- }
1279
- function requestScribeStart(detail) {
1280
- window.dispatchEvent(new CustomEvent(SCRIBE_START_EVENT, { detail }));
1281
- }
1282
-
1283
- // src/lib/prompt.ts
1284
- var PROMPT_VERSION = "scribe/narrative@1";
1285
- function formatOffset(ms) {
1286
- const total = Math.floor(ms / 1e3);
1287
- const mm = String(Math.floor(total / 60)).padStart(2, "0");
1288
- const ss = String(total % 60).padStart(2, "0");
1289
- return `${mm}:${ss}`;
1290
- }
1291
- function renderTranscript(lines) {
1292
- return lines.filter((l) => l.gap || l.text?.trim()).map((l) => {
1293
- const at = formatOffset(l.startOffsetMs);
1294
- if (l.gap) return `[${at}] (((TRECHO AUSENTE \u2014 a grava\xE7\xE3o foi interrompida aqui)))`;
1295
- const who = l.speaker !== void 0 ? `Falante ${l.speaker + 1}: ` : "";
1296
- return `[${at}] ${who}${l.text.trim()}`;
1297
- }).join("\n");
1298
- }
1299
- function shapeInstruction(section) {
1300
- switch (section.shape) {
1301
- case "bullets":
1302
- return "Formato: lista com h\xEDfen. Itens curtos, um fato por item.";
1303
- case "keyvalue":
1304
- return "Formato: lista com h\xEDfen no padr\xE3o `- **R\xF3tulo:** valor`. Um par por linha.";
1305
- default:
1306
- return "Formato: prosa corrida, sem listas.";
1307
- }
1308
- }
1309
- function buildSystemPrompt(schema) {
1310
- const parts = [];
1311
- parts.push(
1312
- "Voc\xEA redige documentos a partir da transcri\xE7\xE3o de um atendimento gravado.",
1313
- "Escreva SOMENTE o que a transcri\xE7\xE3o sustenta. Voc\xEA n\xE3o \xE9 um assistente conversacional: sua sa\xEDda \xE9 o documento, nada mais."
1314
- );
1315
- if (schema.style) parts.push(`Estilo: ${schema.style}`);
1316
- if (schema.outputLocale) parts.push(`Escreva o documento em ${schema.outputLocale}, mesmo que a transcri\xE7\xE3o esteja em outro idioma.`);
1317
- parts.push(
1318
- "",
1319
- "REGRAS INEGOCI\xC1VEIS:",
1320
- "- Nunca invente fato, n\xFAmero, data, nome ou medida que n\xE3o esteja na transcri\xE7\xE3o.",
1321
- '- Quando a transcri\xE7\xE3o n\xE3o cobre algo que a se\xE7\xE3o pede, escreva "n\xE3o informado". N\xE3o deduza a partir do contexto.',
1322
- "- Onde a transcri\xE7\xE3o indicar `(((TRECHO AUSENTE)))`, N\xC3O costure os dois lados como se fossem cont\xEDnuos. Se aquilo afeta o que a se\xE7\xE3o diz, registre a lacuna.",
1323
- "- N\xE3o dirija a palavra ao leitor, n\xE3o comente o pr\xF3prio trabalho, n\xE3o pe\xE7a confirma\xE7\xE3o."
1324
- );
1325
- if (schema.neverInfer?.length) {
1326
- parts.push(
1327
- `- Estes itens NUNCA podem ser inferidos, apenas transcritos quando ditos explicitamente: ${schema.neverInfer.join(", ")}.`
1328
- );
1329
- }
1330
- parts.push(
1331
- "",
1332
- "FORMATO DA SA\xCDDA (markdown restrito):",
1333
- "- Cada se\xE7\xE3o come\xE7a com `## ` seguido exatamente do t\xEDtulo fornecido.",
1334
- "- Use no m\xE1ximo `###` para subt\xEDtulo. Nunca `####` ou mais fundo.",
1335
- "- PROIBIDO: tabela, link, imagem, bloco de c\xF3digo cercado por crases, HTML.",
1336
- "- Permitido: par\xE1grafo, lista com h\xEDfen, lista numerada, **negrito**, *it\xE1lico*.",
1337
- "- N\xE3o escreva pre\xE2mbulo nem fecho. A primeira linha da resposta \xE9 o primeiro `## `."
1338
- );
1339
- return parts.join("\n");
1340
- }
1341
- function buildUserPrompt(args) {
1342
- const { schema, transcript, context } = args;
1343
- const parts = [];
1344
- const ctx = Object.entries(context ?? {}).filter(([, v]) => v);
1345
- if (ctx.length > 0) {
1346
- parts.push("DADOS DO ATENDIMENTO (refer\xEAncia; n\xE3o repita como se\xE7\xE3o):");
1347
- for (const [k, v] of ctx) parts.push(`- ${k}: ${v}`);
1348
- parts.push("");
1349
- }
1350
- parts.push("TRANSCRI\xC7\xC3O:", '"""', transcript, '"""', "");
1351
- parts.push("SE\xC7\xD5ES A ESCREVER, nesta ordem:");
1352
- schema.sections.forEach((section, i) => {
1353
- parts.push("");
1354
- parts.push(`${i + 1}. ## ${section.heading}`);
1355
- parts.push(` O que entra: ${section.guidance}`);
1356
- parts.push(` ${shapeInstruction(section)}`);
1357
- if (section.maxWords) parts.push(` Limite: cerca de ${section.maxWords} palavras.`);
1358
- if (section.required) parts.push(' Obrigat\xF3ria: escreva-a mesmo que s\xF3 para registrar "n\xE3o informado".');
1359
- else if (section.omitWhenEmpty) parts.push(" Se a transcri\xE7\xE3o n\xE3o disser nada sobre isto, OMITA a se\xE7\xE3o inteira, t\xEDtulo incluso.");
1360
- });
1361
- return parts.join("\n");
1362
- }
1363
- function parseSections(markdown, schema) {
1364
- const out = {};
1365
- if (!markdown) return out;
1366
- const norm = (s) => s.trim().toLowerCase().replace(/\s+/g, " ").replace(/[:.]+$/, "");
1367
- const byHeading = new Map(schema.sections.map((s) => [norm(s.heading), s.id]));
1368
- const lines = markdown.split("\n");
1369
- let currentId = null;
1370
- let buffer = [];
1371
- const flush = () => {
1372
- if (currentId) out[currentId] = buffer.join("\n").trim();
1373
- buffer = [];
1374
- };
1375
- for (const line of lines) {
1376
- const match = /^##\s+(.+?)\s*$/.exec(line);
1377
- if (match && !line.startsWith("###")) {
1378
- flush();
1379
- currentId = byHeading.get(norm(match[1])) ?? null;
1380
- continue;
1381
- }
1382
- if (currentId) buffer.push(line);
1383
- }
1384
- flush();
1385
- return out;
1386
- }
1387
- function deriveTitle(markdown, fallback, maxLen = 80) {
1388
- const firstBody = markdown.split("\n").find((l) => l.trim() && !l.startsWith("#") && !l.startsWith("-") && !l.startsWith(">"));
1389
- if (!firstBody) return fallback;
1390
- const clean = firstBody.replace(/[*_`]/g, "").trim();
1391
- return clean.length > maxLen ? `${clean.slice(0, maxLen - 1)}\u2026` : clean;
1392
- }
1393
- function TranscriptPanel({ segments, pending, emptyLabel }) {
1394
- const visible = segments.filter((s) => s.gap || s.text?.trim());
1395
- if (visible.length === 0) {
1396
- return /* @__PURE__ */ jsx("div", { className: "flex flex-col items-center justify-center gap-2 py-12 text-center text-sm text-muted-foreground", children: pending ? /* @__PURE__ */ jsxs(Fragment, { children: [
1397
- /* @__PURE__ */ jsx(Loader2, { className: "h-5 w-5 animate-spin" }),
1398
- /* @__PURE__ */ jsx("p", { children: "Transcrevendo\u2026" }),
1399
- /* @__PURE__ */ jsx("p", { className: "text-xs", children: "O texto aparece conforme os trechos s\xE3o processados." })
1400
- ] }) : /* @__PURE__ */ jsx("p", { children: emptyLabel ?? "Nenhuma transcri\xE7\xE3o ainda." }) });
1401
- }
1402
- return /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
1403
- visible.map((seg) => {
1404
- if (seg.gap) {
1405
- return /* @__PURE__ */ jsxs(
1406
- "div",
1407
- {
1408
- className: "flex items-center gap-2 rounded-md border border-dashed border-amber-500/40 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-400",
1409
- children: [
1410
- /* @__PURE__ */ jsx(AlertTriangle, { className: "h-3.5 w-3.5 shrink-0" }),
1411
- /* @__PURE__ */ jsx("span", { className: "font-mono", children: formatOffset(seg.startOffsetMs) }),
1412
- /* @__PURE__ */ jsxs("span", { children: [
1413
- "Grava\xE7\xE3o interrompida",
1414
- seg.durationMs ? ` por ${Math.round(seg.durationMs / 1e3)}s` : ""
1415
- ] })
1416
- ]
1417
- },
1418
- seg.segIndex
1419
- );
1420
- }
1421
- const speaker = seg.words?.[0]?.sp;
1422
- return /* @__PURE__ */ jsxs("div", { className: "flex gap-3", children: [
1423
- /* @__PURE__ */ jsx("span", { className: "w-12 shrink-0 pt-0.5 font-mono text-xs text-muted-foreground", children: formatOffset(seg.startOffsetMs) }),
1424
- /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
1425
- speaker !== void 0 && /* @__PURE__ */ jsxs(Badge, { variant: "secondary", className: "mb-1 text-[10px]", children: [
1426
- "Falante ",
1427
- speaker + 1
1428
- ] }),
1429
- /* @__PURE__ */ jsxs("p", { className: "text-sm leading-relaxed", children: [
1430
- seg.text,
1431
- seg.partial && /* @__PURE__ */ jsx("span", { className: "ml-1 text-xs text-muted-foreground", children: "(trecho parcial)" })
1432
- ] })
1433
- ] })
1434
- ] }, seg.segIndex);
1435
- }),
1436
- pending && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 pl-15 text-xs text-muted-foreground", children: [
1437
- /* @__PURE__ */ jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }),
1438
- "Transcrevendo o restante\u2026"
1439
- ] })
1440
- ] });
1441
- }
1442
1330
  function client() {
1443
1331
  const supabase = getSupabaseClientOptional();
1444
1332
  if (!supabase) throw new Error("Supabase n\xE3o inicializado");
@@ -1549,9 +1437,33 @@ async function discardGeneration(generationId) {
1549
1437
  const { error } = await client().from(T.generations).update({ status: "discarded", updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", generationId);
1550
1438
  if (error) throw error;
1551
1439
  }
1440
+ async function ensureFormsTemplate(args) {
1441
+ const supabase = client();
1442
+ const { tenantId, key, name, category } = args;
1443
+ const { data: found } = await supabase.from("plg_forms_templates").select("id").eq("tenant_id", tenantId).eq("metadata->>scribePresetKey", key).maybeSingle();
1444
+ if (found?.id) return found.id;
1445
+ const { data: created, error } = await supabase.from("plg_forms_templates").insert({
1446
+ tenant_id: tenantId,
1447
+ name,
1448
+ category: category ?? "general",
1449
+ // A narrative document has no fields: empty schema so the builder opens
1450
+ // without breaking, provenance in metadata.
1451
+ schema: { fields: [], layout: { columns: 12 } },
1452
+ metadata: { scribePresetKey: key, narrative: true }
1453
+ }).select("id").single();
1454
+ if (error || !created) throw error ?? new Error("falha ao criar o modelo do documento");
1455
+ return created.id;
1456
+ }
1552
1457
  async function commitGeneration(args) {
1553
1458
  const supabase = client();
1554
1459
  const { generation, session, markdown, title, sections, edited } = args;
1460
+ const templateId = generation.templateId ?? (generation.templateKey ? await ensureFormsTemplate({
1461
+ tenantId: session.tenantId,
1462
+ key: generation.templateKey,
1463
+ name: args.templateName ?? title,
1464
+ category: args.templateCategory
1465
+ }) : null);
1466
+ if (!templateId) throw new Error("gera\xE7\xE3o sem modelo de documento associado");
1555
1467
  const { data: doc, error: docErr } = await supabase.from("documents").insert({
1556
1468
  tenant_id: session.tenantId,
1557
1469
  kind: "form",
@@ -1573,7 +1485,7 @@ async function commitGeneration(args) {
1573
1485
  const { error: extErr } = await supabase.from("plg_forms_documents").insert({
1574
1486
  document_id: doc.id,
1575
1487
  tenant_id: session.tenantId,
1576
- template_id: generation.templateId ?? null,
1488
+ template_id: templateId,
1577
1489
  data: {
1578
1490
  markdown,
1579
1491
  sections,
@@ -1637,136 +1549,6 @@ async function invokeGenerate(args) {
1637
1549
  if (data?.error) throw new Error(data.error);
1638
1550
  return { generationId: data.generationId, markdown: data.markdown };
1639
1551
  }
1640
- var AUTOSAVE_DEBOUNCE_MS = 2e3;
1641
- function DraftPanel({
1642
- generation,
1643
- session,
1644
- schema,
1645
- templateName,
1646
- labels,
1647
- onRegenerate,
1648
- onCommitted,
1649
- onDiscarded
1650
- }) {
1651
- const original = generation.markdown ?? "";
1652
- const [value, setValue] = React6.useState(generation.markdownEdited ?? original);
1653
- const [saving, setSaving] = React6.useState(false);
1654
- const [committing, setCommitting] = React6.useState(false);
1655
- const timer2 = React6.useRef(null);
1656
- React6.useEffect(() => {
1657
- setValue(generation.markdownEdited ?? generation.markdown ?? "");
1658
- }, [generation.id, generation.markdown, generation.markdownEdited]);
1659
- const edited = value.trim() !== original.trim();
1660
- const handleChange = React6.useCallback(
1661
- (next) => {
1662
- setValue(next);
1663
- if (timer2.current) clearTimeout(timer2.current);
1664
- timer2.current = setTimeout(() => {
1665
- setSaving(true);
1666
- saveDraft(generation.id, next).catch(() => toast.error("N\xE3o foi poss\xEDvel salvar o rascunho")).finally(() => setSaving(false));
1667
- }, AUTOSAVE_DEBOUNCE_MS);
1668
- },
1669
- [generation.id]
1670
- );
1671
- React6.useEffect(() => {
1672
- return () => {
1673
- if (timer2.current) {
1674
- clearTimeout(timer2.current);
1675
- void saveDraft(generation.id, value).catch(() => void 0);
1676
- }
1677
- };
1678
- }, [generation.id, value]);
1679
- const handleCommit = React6.useCallback(async () => {
1680
- setCommitting(true);
1681
- try {
1682
- if (timer2.current) clearTimeout(timer2.current);
1683
- await saveDraft(generation.id, value);
1684
- const documentId = await commitGeneration({
1685
- generation,
1686
- session,
1687
- markdown: value,
1688
- title: generation.title ?? deriveTitle(value, templateName),
1689
- sections: parseSections(value, schema),
1690
- edited
1691
- });
1692
- toast.success("Documento salvo na ficha");
1693
- onCommitted(documentId);
1694
- } catch (err) {
1695
- toast.error(`N\xE3o foi poss\xEDvel salvar: ${err?.message ?? err}`);
1696
- } finally {
1697
- setCommitting(false);
1698
- }
1699
- }, [generation, session, value, schema, templateName, edited, onCommitted]);
1700
- if (generation.status === "running" || generation.status === "pending") {
1701
- return /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center justify-center gap-2 py-16 text-sm text-muted-foreground", children: [
1702
- /* @__PURE__ */ jsx(Loader2, { className: "h-5 w-5 animate-spin" }),
1703
- /* @__PURE__ */ jsxs("p", { children: [
1704
- "Gerando ",
1705
- templateName.toLowerCase(),
1706
- "\u2026"
1707
- ] })
1708
- ] });
1709
- }
1710
- if (generation.status === "failed") {
1711
- return /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center justify-center gap-3 py-16 text-sm", children: [
1712
- /* @__PURE__ */ jsx("p", { className: "text-destructive", children: generation.error ?? "A gera\xE7\xE3o falhou." }),
1713
- /* @__PURE__ */ jsxs(Button, { size: "sm", variant: "outline", onClick: onRegenerate, children: [
1714
- /* @__PURE__ */ jsx(RefreshCw, { className: "mr-1.5 h-4 w-4" }),
1715
- "Tentar novamente"
1716
- ] })
1717
- ] });
1718
- }
1719
- return /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
1720
- /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center justify-between gap-2", children: [
1721
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 text-xs text-muted-foreground", children: [
1722
- edited ? /* @__PURE__ */ jsx(Badge, { variant: "secondary", children: "Editado" }) : /* @__PURE__ */ jsx(Badge, { variant: "outline", children: "Gerado por IA" }),
1723
- saving && /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-1", children: [
1724
- /* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin" }),
1725
- "salvando\u2026"
1726
- ] }),
1727
- generation.model && /* @__PURE__ */ jsxs("span", { className: "hidden sm:inline", children: [
1728
- "\xB7 ",
1729
- generation.model
1730
- ] })
1731
- ] }),
1732
- /* @__PURE__ */ jsxs("div", { className: "flex gap-1.5", children: [
1733
- /* @__PURE__ */ jsxs(Button, { size: "sm", variant: "ghost", onClick: onRegenerate, disabled: committing, children: [
1734
- /* @__PURE__ */ jsx(RefreshCw, { className: "mr-1.5 h-3.5 w-3.5" }),
1735
- "Regerar"
1736
- ] }),
1737
- /* @__PURE__ */ jsxs(
1738
- Button,
1739
- {
1740
- size: "sm",
1741
- variant: "ghost",
1742
- disabled: committing,
1743
- onClick: () => void discardGeneration(generation.id).then(onDiscarded).catch(() => toast.error("Falha ao descartar")),
1744
- children: [
1745
- /* @__PURE__ */ jsx(Trash2, { className: "mr-1.5 h-3.5 w-3.5" }),
1746
- labels.discard
1747
- ]
1748
- }
1749
- ),
1750
- /* @__PURE__ */ jsxs(Button, { size: "sm", onClick: () => void handleCommit(), disabled: committing || !value.trim(), children: [
1751
- committing ? /* @__PURE__ */ jsx(Loader2, { className: "mr-1.5 h-3.5 w-3.5 animate-spin" }) : /* @__PURE__ */ jsx(Check, { className: "mr-1.5 h-3.5 w-3.5" }),
1752
- "Salvar na ficha"
1753
- ] })
1754
- ] })
1755
- ] }),
1756
- /* @__PURE__ */ jsx(
1757
- MarkdownEditor,
1758
- {
1759
- value,
1760
- onChange: handleChange,
1761
- minRows: 20,
1762
- defaultMode: "preview",
1763
- editLabel: "Editar",
1764
- previewLabel: "Visualizar"
1765
- }
1766
- ),
1767
- schema.disclaimer && /* @__PURE__ */ jsx("p", { className: "text-xs italic text-muted-foreground", children: schema.disclaimer })
1768
- ] });
1769
- }
1770
1552
 
1771
1553
  // src/lib/config.ts
1772
1554
  function usesFayzTransport(stt) {
@@ -1862,6 +1644,38 @@ function resolveScribeTransport(stt) {
1862
1644
  return createFayzTransport();
1863
1645
  }
1864
1646
 
1647
+ // src/lib/transcript-clean.ts
1648
+ var ARTIFACTS = [
1649
+ "obrigado por assistir",
1650
+ "obrigado por assistirem",
1651
+ "obrigada por assistir",
1652
+ "legendas pela comunidade amara.org",
1653
+ "legendas pela comunidade",
1654
+ "inscreva-se no canal",
1655
+ "at\xE9 o pr\xF3ximo v\xEDdeo",
1656
+ "tchau tchau",
1657
+ "thanks for watching",
1658
+ "subtitles by the amara.org community",
1659
+ "thank you"
1660
+ ];
1661
+ var FILLERS = ["e a\xED", "e ai", "a\xED", "ai", "ah", "ahn", "hmm", "hm", "uhum", "u\xE9", "n\xE9", "\xF3", "ok", "t\xE1"];
1662
+ var WORD_FILLERS = /* @__PURE__ */ new Set([...FILLERS.flatMap((f) => f.split(" ")), "e"]);
1663
+ function normalize(text) {
1664
+ return text.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[.,!?;:…"'\s]+/g, " ").trim();
1665
+ }
1666
+ function cleanTranscriptText(text) {
1667
+ if (!text) return null;
1668
+ const trimmed = text.trim();
1669
+ if (!trimmed) return null;
1670
+ const flat = normalize(trimmed);
1671
+ if (!flat) return null;
1672
+ if (ARTIFACTS.some((a) => flat === normalize(a))) return null;
1673
+ if (FILLERS.includes(flat)) return null;
1674
+ const words = flat.split(" ").filter(Boolean);
1675
+ if (words.length > 0 && words.length <= 8 && words.every((w) => WORD_FILLERS.has(w))) return null;
1676
+ return trimmed;
1677
+ }
1678
+
1865
1679
  // src/lib/drain.ts
1866
1680
  var CONCURRENCY2 = 3;
1867
1681
  var MAX_ATTEMPTS = 4;
@@ -1878,8 +1692,10 @@ async function drainSessionViaTransport(sessionId, stt) {
1878
1692
  if (!session) throw new Error("session not found");
1879
1693
  const { data: pending } = await supabase.from(T.segments).select("session_id, seg_index, storage_path, stt_attempts").eq("session_id", sessionId).eq("upload_state", "uploaded").in("stt_state", ["pending", "failed"]).lt("stt_attempts", MAX_ATTEMPTS).order("seg_index", { ascending: true });
1880
1694
  if (!pending || pending.length === 0) {
1881
- await recomputeCounters(supabase, sessionId);
1882
- await maybeMarkReady(supabase, sessionId);
1695
+ if (session.ended_at) {
1696
+ await recomputeCounters(supabase, sessionId);
1697
+ await maybeMarkReady(supabase, sessionId);
1698
+ }
1883
1699
  return { transcribed: 0, failed: 0, remaining: 0 };
1884
1700
  }
1885
1701
  let transcribed = 0;
@@ -1929,7 +1745,9 @@ async function transcribeOne(supabase, transport, seg, opts) {
1929
1745
  stt_state: "done",
1930
1746
  stt_provider: result.provider,
1931
1747
  stt_error: null,
1932
- text: result.text,
1748
+ // A clip that came back as pure silence artifact stores empty, not as
1749
+ // something the patient said. See lib/transcript-clean.ts.
1750
+ text: cleanTranscriptText(result.text),
1933
1751
  words: result.words,
1934
1752
  confidence: result.confidence,
1935
1753
  // AUTHORITATIVE duration: measured in the audio, never counted in JS.
@@ -1967,25 +1785,627 @@ async function recomputeCounters(supabase, sessionId) {
1967
1785
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
1968
1786
  }).eq("id", sessionId);
1969
1787
  }
1970
- async function maybeMarkReady(supabase, sessionId) {
1971
- const { data: session } = await supabase.from(T.sessions).select("status, ended_at").eq("id", sessionId).maybeSingle();
1972
- if (!session?.ended_at) return;
1973
- if (!["uploading", "transcribing", "recording", "paused", "interrupted"].includes(session.status)) return;
1974
- const { data: segs } = await supabase.from(T.segments).select("stt_state, stt_attempts, upload_state").eq("session_id", sessionId);
1975
- if (!segs || segs.length === 0) return;
1976
- const outstanding = segs.filter(
1977
- (s) => s.upload_state === "uploaded" && ["pending", "running", "failed"].includes(s.stt_state) && (s.stt_attempts ?? 0) < MAX_ATTEMPTS
1788
+ async function maybeMarkReady(supabase, sessionId) {
1789
+ const { data: session } = await supabase.from(T.sessions).select("status, ended_at").eq("id", sessionId).maybeSingle();
1790
+ if (!session?.ended_at) return;
1791
+ if (!["uploading", "transcribing", "recording", "paused", "interrupted"].includes(session.status)) return;
1792
+ const { data: segs } = await supabase.from(T.segments).select("stt_state, stt_attempts, upload_state").eq("session_id", sessionId);
1793
+ if (!segs || segs.length === 0) return;
1794
+ const outstanding = segs.filter(
1795
+ (s) => s.upload_state === "uploaded" && ["pending", "running", "failed"].includes(s.stt_state) && (s.stt_attempts ?? 0) < MAX_ATTEMPTS
1796
+ );
1797
+ await supabase.from(T.sessions).update({
1798
+ status: outstanding.length === 0 ? "ready" : "transcribing",
1799
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1800
+ }).eq("id", sessionId);
1801
+ }
1802
+
1803
+ // src/lib/live-transcript.ts
1804
+ var POLL_BUSY_MS = 3e3;
1805
+ var POLL_IDLE_MS = 15e3;
1806
+ var useLiveTranscript = create(() => ({
1807
+ sessionId: null,
1808
+ segments: []
1809
+ }));
1810
+ var timer2 = null;
1811
+ var polling = null;
1812
+ var sttConfig = null;
1813
+ var draining = false;
1814
+ var lastSignature = "";
1815
+ async function load(sessionId) {
1816
+ let pendingWork = false;
1817
+ if (sttConfig && !draining) {
1818
+ draining = true;
1819
+ try {
1820
+ const result = await drainSessionViaTransport(sessionId, sttConfig);
1821
+ pendingWork = !!result && (result.transcribed > 0 || result.remaining > 0);
1822
+ } catch {
1823
+ } finally {
1824
+ draining = false;
1825
+ }
1826
+ }
1827
+ try {
1828
+ const segments = await fetchSegments(sessionId);
1829
+ if (polling === sessionId) {
1830
+ const signature = segments.map((s) => `${s.segIndex}:${s.text ? s.text.length : 0}`).join("|");
1831
+ if (signature !== lastSignature) {
1832
+ lastSignature = signature;
1833
+ useLiveTranscript.setState({ sessionId, segments });
1834
+ }
1835
+ }
1836
+ } catch {
1837
+ }
1838
+ return pendingWork ? POLL_BUSY_MS : POLL_IDLE_MS;
1839
+ }
1840
+ function schedule(sessionId, delay) {
1841
+ timer2 = setTimeout(() => {
1842
+ if (polling !== sessionId) return;
1843
+ void load(sessionId).then((next) => {
1844
+ if (polling === sessionId) schedule(sessionId, next);
1845
+ });
1846
+ }, delay);
1847
+ }
1848
+ function watchTranscript(sessionId, stt) {
1849
+ if (stt) sttConfig = stt;
1850
+ if (polling === sessionId) return;
1851
+ polling = sessionId;
1852
+ if (timer2) {
1853
+ clearTimeout(timer2);
1854
+ timer2 = null;
1855
+ }
1856
+ lastSignature = "";
1857
+ if (!sessionId) {
1858
+ useLiveTranscript.setState({ sessionId: null, segments: [] });
1859
+ return;
1860
+ }
1861
+ useLiveTranscript.setState({ sessionId, segments: [] });
1862
+ void load(sessionId).then((next) => {
1863
+ if (polling === sessionId) schedule(sessionId, next);
1864
+ });
1865
+ }
1866
+ function transcriptText() {
1867
+ return useLiveTranscript.getState().segments.map((s) => s.gap ? "[trecho perdido]" : (s.text ?? "").trim()).filter(Boolean).join(" ");
1868
+ }
1869
+
1870
+ // src/components/ScribeAssistantBridge.tsx
1871
+ function useSecondTicker(active) {
1872
+ const [, force] = React7.useReducer((n) => n + 1, 0);
1873
+ React7.useEffect(() => {
1874
+ if (!active) return;
1875
+ const timer3 = setInterval(() => {
1876
+ if (document.visibilityState === "visible") force();
1877
+ }, 250);
1878
+ const onVisible = () => force();
1879
+ document.addEventListener("visibilitychange", onVisible);
1880
+ return () => {
1881
+ clearInterval(timer3);
1882
+ document.removeEventListener("visibilitychange", onVisible);
1883
+ };
1884
+ }, [active]);
1885
+ }
1886
+ function ScribeAssistantBridge({ labels, stt, onOpenSession }) {
1887
+ const state = useScribeStore((s) => s.state);
1888
+ const sessionId = useScribeStore((s) => s.sessionId);
1889
+ const subjectName = useScribeStore((s) => s.subjectName);
1890
+ const live2 = state === "recording" || state === "paused" || state === "interrupted";
1891
+ useSecondTicker(state === "recording");
1892
+ React7.useEffect(() => {
1893
+ watchTranscript(live2 ? sessionId : null, stt);
1894
+ }, [live2, sessionId, stt]);
1895
+ const elapsedMs2 = live2 ? Math.floor(getSnapshot().elapsedMs / 1e3) * 1e3 : 0;
1896
+ const [finished, setFinished] = React7.useState(null);
1897
+ const activity = React7.useMemo(() => {
1898
+ if (live2) {
1899
+ const actions = [
1900
+ state === "paused" ? { intent: "resume", label: labels.resume, run: () => void resumeSession() } : { intent: "pause", label: labels.pause, run: () => void pauseSession() },
1901
+ {
1902
+ intent: "stop",
1903
+ label: labels.finish,
1904
+ primary: true,
1905
+ run: async () => {
1906
+ const id = sessionId;
1907
+ await endSession();
1908
+ if (id) setFinished({ id, label: subjectName ? `${labels.sessionSingular} \xB7 ${subjectName}` : labels.sessionSingular });
1909
+ }
1910
+ }
1911
+ ];
1912
+ return {
1913
+ id: "scribe.session",
1914
+ kind: "recording",
1915
+ label: subjectName ? `${labels.sessionSingular} \xB7 ${subjectName}` : labels.sessionSingular,
1916
+ state: state === "paused" ? "paused" : "running",
1917
+ elapsedMs: elapsedMs2,
1918
+ actions,
1919
+ // Lent, not duplicated: the composer meter draws THIS audio.
1920
+ stream: getCaptureStream
1921
+ };
1922
+ }
1923
+ if (finished) {
1924
+ return {
1925
+ id: "scribe.session",
1926
+ kind: "recording",
1927
+ label: `${finished.label} \u2014 ${labels.generate.toLowerCase()}?`,
1928
+ state: "done",
1929
+ actions: [
1930
+ {
1931
+ intent: "generate",
1932
+ label: labels.generate,
1933
+ primary: true,
1934
+ run: () => {
1935
+ onOpenSession?.(finished.id);
1936
+ setFinished(null);
1937
+ }
1938
+ },
1939
+ { intent: "discard", label: "Depois", run: () => setFinished(null) }
1940
+ ]
1941
+ };
1942
+ }
1943
+ return null;
1944
+ }, [live2, state, elapsedMs2, subjectName, sessionId, finished, labels, onOpenSession]);
1945
+ usePublishAssistantActivity(activity);
1946
+ const liveRef = React7.useRef({ live: live2, state, subjectName, sessionId });
1947
+ liveRef.current = { live: live2, state, subjectName, sessionId };
1948
+ const surface = React7.useMemo(
1949
+ () => ({
1950
+ id: "scribe.session",
1951
+ pluginId: "scribe",
1952
+ describe: () => {
1953
+ const snap = liveRef.current;
1954
+ if (!snap.live) return {};
1955
+ const text = transcriptText();
1956
+ return {
1957
+ title: `${labels.sessionSingular} em grava\xE7\xE3o`,
1958
+ state: {
1959
+ sessionId: snap.sessionId,
1960
+ subject: snap.subjectName ?? null,
1961
+ status: snap.state,
1962
+ elapsed: formatElapsed(getSnapshot().elapsedMs),
1963
+ // Tail-clipped: during an encounter the questions are about what
1964
+ // was just said.
1965
+ transcript: text ? text.slice(-6e3) : null
1966
+ }
1967
+ };
1968
+ },
1969
+ get instructions() {
1970
+ const snap = liveRef.current;
1971
+ if (!snap.live) return void 0;
1972
+ const hasText = transcriptText().length > 0;
1973
+ return `H\xE1 um ${labels.sessionSingular.toLowerCase()} sendo gravado agora` + (snap.subjectName ? ` com ${snap.subjectName}` : "") + ". " + (hasText ? `A transcri\xE7\xE3o parcial est\xE1 em state.transcript \u2014 use S\xD3 o que est\xE1 l\xE1 e diga que o atendimento ainda est\xE1 em curso quando responder sobre ele.` : `Nenhum trecho foi transcrito ainda: os primeiros saem cerca de 30s depois do in\xEDcio. N\xE3o invente o conte\xFAdo.`) + ` O documento final fica pronto quando a grava\xE7\xE3o for finalizada.`;
1974
+ }
1975
+ }),
1976
+ [labels.sessionSingular]
1977
+ );
1978
+ useAgentSurface(surface);
1979
+ const supported = isCaptureSupported();
1980
+ useRegisterAssistantLauncher(
1981
+ live2 ? null : {
1982
+ id: "scribe.start",
1983
+ kind: "recording",
1984
+ label: labels.start,
1985
+ disabled: !supported,
1986
+ disabledReason: "Este navegador n\xE3o suporta grava\xE7\xE3o de \xE1udio",
1987
+ run: () => {
1988
+ openAssistant();
1989
+ requestScribeStart({});
1990
+ }
1991
+ }
1992
+ );
1993
+ return null;
1994
+ }
1995
+ var SCRIBE_START_EVENT = "scribe:start";
1996
+ function ScribeShellMount({ labels, retention: retention2, stt, locale, onOpenSession }) {
1997
+ const { user } = useAuth();
1998
+ const railOffset = useRightRailStore((s) => s.open ? s.width : 0);
1999
+ const contentTop = useContentTop();
2000
+ const bannerRef = React7.useRef(null);
2001
+ useReserveSpace(bannerRef);
2002
+ const [pending, setPending] = React7.useState(null);
2003
+ React7.useEffect(() => {
2004
+ wireScribeStore();
2005
+ }, []);
2006
+ React7.useEffect(() => {
2007
+ const onStart = (ev) => {
2008
+ const detail = ev.detail ?? {};
2009
+ if (retention2.requireConsent) setPending(detail);
2010
+ else void start(detail, retention2.defaultConsentMode);
2011
+ };
2012
+ window.addEventListener(SCRIBE_START_EVENT, onStart);
2013
+ return () => window.removeEventListener(SCRIBE_START_EVENT, onStart);
2014
+ }, [retention2.requireConsent, retention2.defaultConsentMode, user?.id, locale]);
2015
+ const start = React7.useCallback(
2016
+ async (detail, consentMode) => {
2017
+ if (!user?.id) {
2018
+ toast.error("Fa\xE7a login para gravar um atendimento");
2019
+ return;
2020
+ }
2021
+ const result = await beginSession({
2022
+ userId: user.id,
2023
+ subjectId: detail.subjectId,
2024
+ subjectName: detail.subjectName,
2025
+ appointmentId: detail.appointmentId,
2026
+ templateKeys: detail.templateKeys,
2027
+ locale,
2028
+ consentMode
2029
+ });
2030
+ if (!result.ok) {
2031
+ toast.error(`N\xE3o foi poss\xEDvel iniciar: ${result.reason}`);
2032
+ return;
2033
+ }
2034
+ toast.success(`${labels.sessionSingular} iniciado`);
2035
+ },
2036
+ [user?.id, locale, labels.sessionSingular]
2037
+ );
2038
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
2039
+ /* @__PURE__ */ jsx(ScribeAssistantBridge, { labels, stt, onOpenSession }),
2040
+ /* @__PURE__ */ jsx(ScribeRecordingPill, { labels, onOpenSession, mobileOnly: true }),
2041
+ /* @__PURE__ */ jsx(
2042
+ ScribeConsentDialog,
2043
+ {
2044
+ open: pending !== null,
2045
+ labels,
2046
+ subjectName: pending?.subjectName,
2047
+ defaultConsentMode: retention2.defaultConsentMode,
2048
+ onCancel: () => setPending(null),
2049
+ onConfirm: (mode) => {
2050
+ const detail = pending;
2051
+ setPending(null);
2052
+ if (detail) void start(detail, mode);
2053
+ }
2054
+ }
2055
+ ),
2056
+ typeof document !== "undefined" && createPortal(
2057
+ /* @__PURE__ */ jsx(
2058
+ "div",
2059
+ {
2060
+ ref: bannerRef,
2061
+ className: "pointer-events-auto fixed left-0 z-40",
2062
+ style: { top: contentTop, right: railOffset },
2063
+ children: /* @__PURE__ */ jsx(ScribeRecoveryBanner, { labels, onOpenSession })
2064
+ }
2065
+ ),
2066
+ document.body
2067
+ )
2068
+ ] });
2069
+ }
2070
+ function useReserveSpace(ref) {
2071
+ React7.useEffect(() => {
2072
+ if (typeof document === "undefined") return;
2073
+ const main = document.querySelector("main");
2074
+ const node = ref.current;
2075
+ if (!main || !node) return;
2076
+ const previous = main.style.paddingTop;
2077
+ const apply = () => {
2078
+ const height = node.getBoundingClientRect().height;
2079
+ main.style.paddingTop = height > 0 ? `${Math.ceil(height)}px` : previous;
2080
+ };
2081
+ apply();
2082
+ const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(apply) : null;
2083
+ observer?.observe(node);
2084
+ return () => {
2085
+ observer?.disconnect();
2086
+ main.style.paddingTop = previous;
2087
+ };
2088
+ }, [ref]);
2089
+ }
2090
+ function useContentTop() {
2091
+ const [top, setTop] = React7.useState(56);
2092
+ React7.useEffect(() => {
2093
+ if (typeof document === "undefined") return;
2094
+ const measure = () => {
2095
+ const main2 = document.querySelector("main");
2096
+ if (main2) setTop(Math.round(main2.getBoundingClientRect().top));
2097
+ };
2098
+ measure();
2099
+ window.addEventListener("resize", measure);
2100
+ const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null;
2101
+ const main = document.querySelector("main");
2102
+ if (observer && main) observer.observe(main);
2103
+ return () => {
2104
+ window.removeEventListener("resize", measure);
2105
+ observer?.disconnect();
2106
+ };
2107
+ }, []);
2108
+ return top;
2109
+ }
2110
+ function requestScribeStart(detail) {
2111
+ window.dispatchEvent(new CustomEvent(SCRIBE_START_EVENT, { detail }));
2112
+ }
2113
+
2114
+ // src/lib/prompt.ts
2115
+ var PROMPT_VERSION = "scribe/narrative@1";
2116
+ function formatOffset(ms) {
2117
+ const total = Math.floor(ms / 1e3);
2118
+ const mm = String(Math.floor(total / 60)).padStart(2, "0");
2119
+ const ss = String(total % 60).padStart(2, "0");
2120
+ return `${mm}:${ss}`;
2121
+ }
2122
+ function renderTranscript(lines) {
2123
+ return lines.filter((l) => l.gap || l.text?.trim()).map((l) => {
2124
+ const at = formatOffset(l.startOffsetMs);
2125
+ if (l.gap) return `[${at}] (((TRECHO AUSENTE \u2014 a grava\xE7\xE3o foi interrompida aqui)))`;
2126
+ const who = l.speaker !== void 0 ? `Falante ${l.speaker + 1}: ` : "";
2127
+ return `[${at}] ${who}${l.text.trim()}`;
2128
+ }).join("\n");
2129
+ }
2130
+ function shapeInstruction(section) {
2131
+ switch (section.shape) {
2132
+ case "bullets":
2133
+ return "Formato: lista com h\xEDfen. Itens curtos, um fato por item.";
2134
+ case "keyvalue":
2135
+ return "Formato: lista com h\xEDfen no padr\xE3o `- **R\xF3tulo:** valor`. Um par por linha.";
2136
+ default:
2137
+ return "Formato: prosa corrida, sem listas.";
2138
+ }
2139
+ }
2140
+ function buildSystemPrompt(schema) {
2141
+ const parts = [];
2142
+ parts.push(
2143
+ "Voc\xEA redige documentos a partir da transcri\xE7\xE3o de um atendimento gravado.",
2144
+ "Escreva SOMENTE o que a transcri\xE7\xE3o sustenta. Voc\xEA n\xE3o \xE9 um assistente conversacional: sua sa\xEDda \xE9 o documento, nada mais."
2145
+ );
2146
+ if (schema.style) parts.push(`Estilo: ${schema.style}`);
2147
+ if (schema.outputLocale) parts.push(`Escreva o documento em ${schema.outputLocale}, mesmo que a transcri\xE7\xE3o esteja em outro idioma.`);
2148
+ parts.push(
2149
+ "",
2150
+ "REGRAS INEGOCI\xC1VEIS:",
2151
+ "- Nunca invente fato, n\xFAmero, data, nome ou medida que n\xE3o esteja na transcri\xE7\xE3o.",
2152
+ '- Quando a transcri\xE7\xE3o n\xE3o cobre algo que a se\xE7\xE3o pede, escreva "n\xE3o informado". N\xE3o deduza a partir do contexto.',
2153
+ "- Onde a transcri\xE7\xE3o indicar `(((TRECHO AUSENTE)))`, N\xC3O costure os dois lados como se fossem cont\xEDnuos. Se aquilo afeta o que a se\xE7\xE3o diz, registre a lacuna.",
2154
+ "- N\xE3o dirija a palavra ao leitor, n\xE3o comente o pr\xF3prio trabalho, n\xE3o pe\xE7a confirma\xE7\xE3o."
2155
+ );
2156
+ if (schema.neverInfer?.length) {
2157
+ parts.push(
2158
+ `- Estes itens NUNCA podem ser inferidos, apenas transcritos quando ditos explicitamente: ${schema.neverInfer.join(", ")}.`
2159
+ );
2160
+ }
2161
+ parts.push(
2162
+ "",
2163
+ "FORMATO DA SA\xCDDA (markdown restrito):",
2164
+ "- Cada se\xE7\xE3o come\xE7a com `## ` seguido exatamente do t\xEDtulo fornecido.",
2165
+ "- Use no m\xE1ximo `###` para subt\xEDtulo. Nunca `####` ou mais fundo.",
2166
+ "- PROIBIDO: tabela, link, imagem, bloco de c\xF3digo cercado por crases, HTML.",
2167
+ "- Permitido: par\xE1grafo, lista com h\xEDfen, lista numerada, **negrito**, *it\xE1lico*.",
2168
+ "- N\xE3o escreva pre\xE2mbulo nem fecho. A primeira linha da resposta \xE9 o primeiro `## `."
2169
+ );
2170
+ return parts.join("\n");
2171
+ }
2172
+ function buildUserPrompt(args) {
2173
+ const { schema, transcript, context } = args;
2174
+ const parts = [];
2175
+ const ctx = Object.entries(context ?? {}).filter(([, v]) => v);
2176
+ if (ctx.length > 0) {
2177
+ parts.push("DADOS DO ATENDIMENTO (refer\xEAncia; n\xE3o repita como se\xE7\xE3o):");
2178
+ for (const [k, v] of ctx) parts.push(`- ${k}: ${v}`);
2179
+ parts.push("");
2180
+ }
2181
+ parts.push("TRANSCRI\xC7\xC3O:", '"""', transcript, '"""', "");
2182
+ parts.push("SE\xC7\xD5ES A ESCREVER, nesta ordem:");
2183
+ schema.sections.forEach((section, i) => {
2184
+ parts.push("");
2185
+ parts.push(`${i + 1}. ## ${section.heading}`);
2186
+ parts.push(` O que entra: ${section.guidance}`);
2187
+ parts.push(` ${shapeInstruction(section)}`);
2188
+ if (section.maxWords) parts.push(` Limite: cerca de ${section.maxWords} palavras.`);
2189
+ if (section.required) parts.push(' Obrigat\xF3ria: escreva-a mesmo que s\xF3 para registrar "n\xE3o informado".');
2190
+ else if (section.omitWhenEmpty) parts.push(" Se a transcri\xE7\xE3o n\xE3o disser nada sobre isto, OMITA a se\xE7\xE3o inteira, t\xEDtulo incluso.");
2191
+ });
2192
+ return parts.join("\n");
2193
+ }
2194
+ function parseSections(markdown, schema) {
2195
+ const out = {};
2196
+ if (!markdown) return out;
2197
+ const norm = (s) => s.trim().toLowerCase().replace(/\s+/g, " ").replace(/[:.]+$/, "");
2198
+ const byHeading = new Map(schema.sections.map((s) => [norm(s.heading), s.id]));
2199
+ const lines = markdown.split("\n");
2200
+ let currentId = null;
2201
+ let buffer = [];
2202
+ const flush = () => {
2203
+ if (currentId) out[currentId] = buffer.join("\n").trim();
2204
+ buffer = [];
2205
+ };
2206
+ for (const line of lines) {
2207
+ const match = /^##\s+(.+?)\s*$/.exec(line);
2208
+ if (match && !line.startsWith("###")) {
2209
+ flush();
2210
+ currentId = byHeading.get(norm(match[1])) ?? null;
2211
+ continue;
2212
+ }
2213
+ if (currentId) buffer.push(line);
2214
+ }
2215
+ flush();
2216
+ return out;
2217
+ }
2218
+ function TranscriptPanel({ segments, pending, emptyLabel }) {
2219
+ const visible = segments.filter((s) => s.gap || s.text?.trim());
2220
+ if (visible.length === 0) {
2221
+ return /* @__PURE__ */ jsx("div", { className: "flex flex-col items-center justify-center gap-2 py-12 text-center text-sm text-muted-foreground", children: pending ? /* @__PURE__ */ jsxs(Fragment, { children: [
2222
+ /* @__PURE__ */ jsx(Loader2, { className: "h-5 w-5 animate-spin" }),
2223
+ /* @__PURE__ */ jsx("p", { children: "Transcrevendo\u2026" }),
2224
+ /* @__PURE__ */ jsx("p", { className: "text-xs", children: "O texto aparece conforme os trechos s\xE3o processados." })
2225
+ ] }) : /* @__PURE__ */ jsx("p", { children: emptyLabel ?? "Nenhuma transcri\xE7\xE3o ainda." }) });
2226
+ }
2227
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
2228
+ visible.map((seg) => {
2229
+ if (seg.gap) {
2230
+ return /* @__PURE__ */ jsxs(
2231
+ "div",
2232
+ {
2233
+ className: "flex items-center gap-2 rounded-md border border-dashed border-amber-500/40 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-400",
2234
+ children: [
2235
+ /* @__PURE__ */ jsx(AlertTriangle, { className: "h-3.5 w-3.5 shrink-0" }),
2236
+ /* @__PURE__ */ jsx("span", { className: "font-mono", children: formatOffset(seg.startOffsetMs) }),
2237
+ /* @__PURE__ */ jsxs("span", { children: [
2238
+ "Grava\xE7\xE3o interrompida",
2239
+ seg.durationMs ? ` por ${Math.round(seg.durationMs / 1e3)}s` : ""
2240
+ ] })
2241
+ ]
2242
+ },
2243
+ seg.segIndex
2244
+ );
2245
+ }
2246
+ const speaker = seg.words?.[0]?.sp;
2247
+ return /* @__PURE__ */ jsxs("div", { className: "flex gap-3", children: [
2248
+ /* @__PURE__ */ jsx("span", { className: "w-12 shrink-0 pt-0.5 font-mono text-xs text-muted-foreground", children: formatOffset(seg.startOffsetMs) }),
2249
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
2250
+ speaker !== void 0 && /* @__PURE__ */ jsxs(Badge, { variant: "secondary", className: "mb-1 text-[10px]", children: [
2251
+ "Falante ",
2252
+ speaker + 1
2253
+ ] }),
2254
+ /* @__PURE__ */ jsxs("p", { className: "text-sm leading-relaxed", children: [
2255
+ seg.text,
2256
+ seg.partial && /* @__PURE__ */ jsx("span", { className: "ml-1 text-xs text-muted-foreground", children: "(trecho parcial)" })
2257
+ ] })
2258
+ ] })
2259
+ ] }, seg.segIndex);
2260
+ }),
2261
+ pending && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 pl-15 text-xs text-muted-foreground", children: [
2262
+ /* @__PURE__ */ jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }),
2263
+ "Transcrevendo o restante\u2026"
2264
+ ] })
2265
+ ] });
2266
+ }
2267
+ var AUTOSAVE_DEBOUNCE_MS = 2e3;
2268
+ function DraftPanel({
2269
+ generation,
2270
+ session,
2271
+ schema,
2272
+ templateName,
2273
+ templateCategory,
2274
+ labels,
2275
+ onRegenerate,
2276
+ onCommitted,
2277
+ onDiscarded
2278
+ }) {
2279
+ const original = generation.markdown ?? "";
2280
+ const [value, setValue] = React7.useState(generation.markdownEdited ?? original);
2281
+ const [saving, setSaving] = React7.useState(false);
2282
+ const [committing, setCommitting] = React7.useState(false);
2283
+ const timer3 = React7.useRef(null);
2284
+ React7.useEffect(() => {
2285
+ setValue(generation.markdownEdited ?? generation.markdown ?? "");
2286
+ }, [generation.id, generation.markdown, generation.markdownEdited]);
2287
+ const edited = value.trim() !== original.trim();
2288
+ const handleChange = React7.useCallback(
2289
+ (next) => {
2290
+ setValue(next);
2291
+ if (timer3.current) clearTimeout(timer3.current);
2292
+ timer3.current = setTimeout(() => {
2293
+ setSaving(true);
2294
+ saveDraft(generation.id, next).catch(() => toast.error("N\xE3o foi poss\xEDvel salvar o rascunho")).finally(() => setSaving(false));
2295
+ }, AUTOSAVE_DEBOUNCE_MS);
2296
+ },
2297
+ [generation.id]
1978
2298
  );
1979
- await supabase.from(T.sessions).update({
1980
- status: outstanding.length === 0 ? "ready" : "transcribing",
1981
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
1982
- }).eq("id", sessionId);
2299
+ React7.useEffect(() => {
2300
+ return () => {
2301
+ if (timer3.current) {
2302
+ clearTimeout(timer3.current);
2303
+ void saveDraft(generation.id, value).catch(() => void 0);
2304
+ }
2305
+ };
2306
+ }, [generation.id, value]);
2307
+ const handleCommit = React7.useCallback(async () => {
2308
+ setCommitting(true);
2309
+ try {
2310
+ if (timer3.current) clearTimeout(timer3.current);
2311
+ await saveDraft(generation.id, value);
2312
+ const documentId = await commitGeneration({
2313
+ generation,
2314
+ session,
2315
+ markdown: value,
2316
+ // The document's name is its TYPE. `deriveTitle` took the first line
2317
+ // of the text, so the record listed "Paciente refere dor importante
2318
+ // em cotovelo…" where it should list "Anamnese" — and two of them for
2319
+ // the same patient were indistinguishable.
2320
+ title: templateName,
2321
+ sections: parseSections(value, schema),
2322
+ edited,
2323
+ templateName,
2324
+ templateCategory
2325
+ });
2326
+ toast.success("Documento salvo na ficha");
2327
+ onCommitted(documentId);
2328
+ } catch (err) {
2329
+ toast.error(`N\xE3o foi poss\xEDvel salvar: ${err?.message ?? err}`);
2330
+ } finally {
2331
+ setCommitting(false);
2332
+ }
2333
+ }, [generation, session, value, schema, templateName, templateCategory, edited, onCommitted]);
2334
+ if (generation.status === "running" || generation.status === "pending") {
2335
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center justify-center gap-2 py-16 text-sm text-muted-foreground", children: [
2336
+ /* @__PURE__ */ jsx(Loader2, { className: "h-5 w-5 animate-spin" }),
2337
+ /* @__PURE__ */ jsxs("p", { children: [
2338
+ "Gerando ",
2339
+ templateName.toLowerCase(),
2340
+ "\u2026"
2341
+ ] })
2342
+ ] });
2343
+ }
2344
+ if (generation.status === "failed") {
2345
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center justify-center gap-3 py-16 text-sm", children: [
2346
+ /* @__PURE__ */ jsx("p", { className: "text-destructive", children: generation.error ?? "A gera\xE7\xE3o falhou." }),
2347
+ /* @__PURE__ */ jsxs(Button, { size: "sm", variant: "outline", onClick: onRegenerate, children: [
2348
+ /* @__PURE__ */ jsx(RefreshCw, { className: "mr-1.5 h-4 w-4" }),
2349
+ "Tentar novamente"
2350
+ ] })
2351
+ ] });
2352
+ }
2353
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
2354
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center justify-between gap-2", children: [
2355
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 text-xs text-muted-foreground", children: [
2356
+ edited ? /* @__PURE__ */ jsx(Badge, { variant: "secondary", children: "Editado" }) : /* @__PURE__ */ jsx(Badge, { variant: "outline", children: "Gerado por IA" }),
2357
+ saving && /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-1", children: [
2358
+ /* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin" }),
2359
+ "salvando\u2026"
2360
+ ] }),
2361
+ generation.model && /* @__PURE__ */ jsxs("span", { className: "hidden sm:inline", children: [
2362
+ "\xB7 ",
2363
+ generation.model
2364
+ ] })
2365
+ ] }),
2366
+ /* @__PURE__ */ jsxs("div", { className: "flex gap-1.5", children: [
2367
+ /* @__PURE__ */ jsxs(Button, { size: "sm", variant: "ghost", onClick: onRegenerate, disabled: committing, children: [
2368
+ /* @__PURE__ */ jsx(RefreshCw, { className: "mr-1.5 h-3.5 w-3.5" }),
2369
+ "Regerar"
2370
+ ] }),
2371
+ /* @__PURE__ */ jsxs(
2372
+ Button,
2373
+ {
2374
+ size: "sm",
2375
+ variant: "ghost",
2376
+ disabled: committing,
2377
+ onClick: () => void discardGeneration(generation.id).then(onDiscarded).catch(() => toast.error("Falha ao descartar")),
2378
+ children: [
2379
+ /* @__PURE__ */ jsx(Trash2, { className: "mr-1.5 h-3.5 w-3.5" }),
2380
+ labels.discard
2381
+ ]
2382
+ }
2383
+ ),
2384
+ /* @__PURE__ */ jsxs(Button, { size: "sm", onClick: () => void handleCommit(), disabled: committing || !value.trim(), children: [
2385
+ committing ? /* @__PURE__ */ jsx(Loader2, { className: "mr-1.5 h-3.5 w-3.5 animate-spin" }) : /* @__PURE__ */ jsx(Check, { className: "mr-1.5 h-3.5 w-3.5" }),
2386
+ "Salvar na ficha"
2387
+ ] })
2388
+ ] })
2389
+ ] }),
2390
+ /* @__PURE__ */ jsx(
2391
+ MarkdownEditor,
2392
+ {
2393
+ value,
2394
+ onChange: handleChange,
2395
+ minRows: 20,
2396
+ defaultMode: "preview",
2397
+ editLabel: "Editar",
2398
+ previewLabel: "Visualizar"
2399
+ }
2400
+ ),
2401
+ schema.disclaimer && /* @__PURE__ */ jsx("p", { className: "text-xs italic text-muted-foreground", children: schema.disclaimer })
2402
+ ] });
1983
2403
  }
1984
2404
  var TEMPERATURE = 0.2;
1985
2405
  var MAX_TOKENS = 4e3;
1986
2406
  function client3() {
1987
2407
  const supabase = getSupabaseClientOptional();
1988
- if (!supabase) throw new Error("Supabase n\xE3o inicializado");
2408
+ if (!supabase) throw new Error("Supabase not initialised");
1989
2409
  return supabase;
1990
2410
  }
1991
2411
  async function generateViaTransport(args) {
@@ -2039,7 +2459,9 @@ async function generateViaTransport(args) {
2039
2459
  await supabase.from(T.generations).update({
2040
2460
  status: "ready",
2041
2461
  markdown,
2042
- title: deriveTitle(markdown, args.templateName),
2462
+ // The document TYPE, not a summary of it: "Anamnese" identifies it;
2463
+ // the first sentence changes with every generation.
2464
+ title: args.templateName,
2043
2465
  model: result.model,
2044
2466
  input_tokens: result.tokensIn ?? null,
2045
2467
  output_tokens: result.tokensOut ?? null,
@@ -2066,6 +2488,7 @@ async function loadTranscript(supabase, sessionId) {
2066
2488
  var POLL_MS = 5e3;
2067
2489
  function ScribeSessionPage({
2068
2490
  sessionId,
2491
+ embedded,
2069
2492
  labels,
2070
2493
  presets,
2071
2494
  stt,
@@ -2073,14 +2496,14 @@ function ScribeSessionPage({
2073
2496
  onOpenDocument,
2074
2497
  onBack
2075
2498
  }) {
2076
- const [session, setSession] = React6.useState(null);
2077
- const [segments, setSegments] = React6.useState([]);
2078
- const [generations, setGenerations] = React6.useState([]);
2079
- const [loading, setLoading] = React6.useState(true);
2080
- const [generating, setGenerating] = React6.useState(false);
2081
- const [tab, setTab] = React6.useState("transcript");
2082
- const draining = React6.useRef(false);
2083
- const refresh = React6.useCallback(async () => {
2499
+ const [session, setSession] = React7.useState(null);
2500
+ const [segments, setSegments] = React7.useState([]);
2501
+ const [generations, setGenerations] = React7.useState([]);
2502
+ const [loading, setLoading] = React7.useState(true);
2503
+ const [generating, setGenerating] = React7.useState(false);
2504
+ const [tab, setTab] = React7.useState("transcript");
2505
+ const draining2 = React7.useRef(false);
2506
+ const refresh = React7.useCallback(async () => {
2084
2507
  const [s, segs, gens] = await Promise.all([
2085
2508
  fetchSession(sessionId),
2086
2509
  fetchSegments(sessionId),
@@ -2091,21 +2514,21 @@ function ScribeSessionPage({
2091
2514
  setGenerations(gens);
2092
2515
  return { session: s, segments: segs };
2093
2516
  }, [sessionId]);
2094
- React6.useEffect(() => {
2517
+ React7.useEffect(() => {
2095
2518
  setLoading(true);
2096
2519
  void refresh().finally(() => setLoading(false));
2097
2520
  }, [refresh]);
2098
- const pendingStt = React6.useMemo(
2521
+ const pendingStt = React7.useMemo(
2099
2522
  () => segments.filter((s) => s.uploadState === "uploaded" && ["pending", "failed"].includes(s.sttState) && s.sttAttempts < 4),
2100
2523
  [segments]
2101
2524
  );
2102
2525
  const busy = pendingStt.length > 0 || segments.some((s) => s.sttState === "running");
2103
- React6.useEffect(() => {
2526
+ React7.useEffect(() => {
2104
2527
  if (!busy) return;
2105
2528
  const id = setInterval(() => {
2106
2529
  void (async () => {
2107
- if (draining.current) return;
2108
- draining.current = true;
2530
+ if (draining2.current) return;
2531
+ draining2.current = true;
2109
2532
  try {
2110
2533
  if (pendingStt.length > 0) {
2111
2534
  const drained = await drainSessionViaTransport(sessionId, stt);
@@ -2122,14 +2545,14 @@ function ScribeSessionPage({
2122
2545
  await refresh();
2123
2546
  } catch {
2124
2547
  } finally {
2125
- draining.current = false;
2548
+ draining2.current = false;
2126
2549
  }
2127
2550
  })();
2128
2551
  }, POLL_MS);
2129
2552
  return () => clearInterval(id);
2130
2553
  }, [busy, pendingStt.length, sessionId, stt, refresh]);
2131
2554
  const hasTranscript = segments.some((s) => !s.gap && s.text?.trim());
2132
- const handleGenerate = React6.useCallback(
2555
+ const handleGenerate = React7.useCallback(
2133
2556
  async (preset) => {
2134
2557
  setGenerating(true);
2135
2558
  try {
@@ -2160,7 +2583,7 @@ function ScribeSessionPage({
2160
2583
  );
2161
2584
  const activeGeneration = generations.find((g) => g.id === tab);
2162
2585
  useAgentSurface(
2163
- React6.useMemo(
2586
+ React7.useMemo(
2164
2587
  () => ({
2165
2588
  id: "scribe.session",
2166
2589
  describe: () => ({
@@ -2183,7 +2606,24 @@ function ScribeSessionPage({
2183
2606
  )
2184
2607
  );
2185
2608
  if (loading) {
2186
- return /* @__PURE__ */ jsx("div", { className: "flex justify-center py-16", children: /* @__PURE__ */ jsx(Loader2, { className: "h-6 w-6 animate-spin text-muted-foreground" }) });
2609
+ return (
2610
+ // Same silhouette as the loaded screen, so nothing jumps on arrival.
2611
+ /* @__PURE__ */ jsxs("div", { className: "space-y-4", "aria-busy": "true", children: [
2612
+ /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
2613
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-5 w-40 rounded-full" }),
2614
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-5 w-28 rounded-full" }),
2615
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-5 w-20 rounded-full" })
2616
+ ] }),
2617
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-8 w-32 rounded-lg" }),
2618
+ /* @__PURE__ */ jsx("div", { className: "space-y-3 rounded-xl border p-4", children: [0, 1, 2].map((row) => /* @__PURE__ */ jsxs("div", { className: "flex gap-3", children: [
2619
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-3 w-10 shrink-0" }),
2620
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1 space-y-1.5", children: [
2621
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-3 w-full" }),
2622
+ /* @__PURE__ */ jsx(Skeleton, { className: row === 1 ? "h-3 w-2/5" : "h-3 w-4/5" })
2623
+ ] })
2624
+ ] }, row)) })
2625
+ ] })
2626
+ );
2187
2627
  }
2188
2628
  if (!session) {
2189
2629
  return /* @__PURE__ */ jsx("p", { className: "py-16 text-center text-sm text-muted-foreground", children: "Sess\xE3o n\xE3o encontrada." });
@@ -2195,7 +2635,8 @@ function ScribeSessionPage({
2195
2635
  {
2196
2636
  title: labels.sessionSingular,
2197
2637
  parentLabel: onBack ? labels.sessionPlural : void 0,
2198
- onBack
2638
+ onBack,
2639
+ inline: embedded
2199
2640
  }
2200
2641
  ),
2201
2642
  /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2 text-sm text-muted-foreground", children: [
@@ -2252,6 +2693,7 @@ function ScribeSessionPage({
2252
2693
  session,
2253
2694
  schema: preset.schema,
2254
2695
  templateName: preset.name,
2696
+ templateCategory: preset.category,
2255
2697
  labels,
2256
2698
  onRegenerate: () => void handleGenerate(preset),
2257
2699
  onCommitted: (documentId) => {
@@ -2283,6 +2725,26 @@ function statusLabel(status) {
2283
2725
  };
2284
2726
  return map[status] ?? status;
2285
2727
  }
2728
+ function ScribeHeaderAction({ labels, item, embedded }) {
2729
+ const subjectId = item?.personId ?? item?.id;
2730
+ const subjectName = item?.name ?? item?.full_name;
2731
+ const { entitled } = useAccessOptional();
2732
+ if (!subjectId) return null;
2733
+ if (!entitled("scribe")) return null;
2734
+ if (!isCaptureSupported()) return null;
2735
+ return /* @__PURE__ */ jsx(
2736
+ ScribeStartButton,
2737
+ {
2738
+ labels,
2739
+ variant: "outline",
2740
+ size: embedded ? "sm" : "sm",
2741
+ onStart: () => {
2742
+ openAssistant();
2743
+ requestScribeStart({ subjectId, subjectName });
2744
+ }
2745
+ }
2746
+ );
2747
+ }
2286
2748
 
2287
2749
  // src/migrations/index.ts
2288
2750
  var MIGRATION_001_SCRIBE_BASE = `-- plugin-scribe 001: gravar um atendimento inteiro sem poder perd\xEA-lo.
@@ -2763,6 +3225,301 @@ CREATE POLICY plg_scribe_audio_delete ON storage.objects
2763
3225
  AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)
2764
3226
  );
2765
3227
  `;
3228
+ var STATUS_LABELS = {
3229
+ recording: "Gravando",
3230
+ paused: "Pausado",
3231
+ interrupted: "Interrompido",
3232
+ uploading: "Enviando",
3233
+ transcribing: "Transcrevendo",
3234
+ ready: "Pronto",
3235
+ generating: "Rascunho",
3236
+ completed: "Conclu\xEDdo",
3237
+ abandoned: "Descartado",
3238
+ failed: "Falhou"
3239
+ };
3240
+ function minutes(ms) {
3241
+ const value = Number(ms);
3242
+ if (!Number.isFinite(value) || value <= 0) return void 0;
3243
+ const total = Math.round(value / 6e4);
3244
+ return total < 1 ? "menos de 1 min" : `${total} min de \xE1udio`;
3245
+ }
3246
+ function registerScribeTimelineSource() {
3247
+ registerTimelineSource({
3248
+ id: "recordings",
3249
+ label: "Grava\xE7\xF5es",
3250
+ icon: "Mic",
3251
+ // `magic` é o tom que o SDK já reserva para o que a IA produz.
3252
+ tone: "magic",
3253
+ async fetch(args) {
3254
+ const db = getSupabaseClientOptional();
3255
+ if (!db) return [];
3256
+ let query = db.from("plg_scribe_sessions").select("id, status, started_at, audio_duration_ms, transcript_chars").eq("tenant_id", args.tenantId).eq("subject_id", args.personId).order("started_at", { ascending: false }).limit(args.limit);
3257
+ if (args.before) query = query.lt("started_at", args.before);
3258
+ const { data, error } = await query;
3259
+ if (error) return [];
3260
+ return (data ?? []).map((row) => ({
3261
+ id: `scribe:${row.id}`,
3262
+ sourceId: "recordings",
3263
+ occurredAt: row.started_at,
3264
+ title: "Atendimento gravado",
3265
+ description: minutes(row.audio_duration_ms),
3266
+ badge: STATUS_LABELS[row.status] ?? row.status ?? void 0,
3267
+ icon: "Mic",
3268
+ targetTab: "scribe"
3269
+ }));
3270
+ }
3271
+ });
3272
+ }
3273
+ var BARS = 8;
3274
+ function DeviceLevel({ deviceId, onSignal }) {
3275
+ const barsRef = React7.useRef([]);
3276
+ React7.useEffect(() => {
3277
+ let cancelled = false;
3278
+ let stream = null;
3279
+ let context = null;
3280
+ let frame = 0;
3281
+ let sawSignal = false;
3282
+ const AudioCtor = typeof window === "undefined" ? void 0 : window.AudioContext ?? window.webkitAudioContext;
3283
+ if (!AudioCtor) return;
3284
+ void navigator.mediaDevices.getUserMedia({ audio: { deviceId: { exact: deviceId } } }).then((granted) => {
3285
+ if (cancelled) {
3286
+ granted.getTracks().forEach((t) => t.stop());
3287
+ return;
3288
+ }
3289
+ stream = granted;
3290
+ context = new AudioCtor();
3291
+ const analyser = context.createAnalyser();
3292
+ analyser.fftSize = 128;
3293
+ analyser.smoothingTimeConstant = 0.7;
3294
+ context.createMediaStreamSource(granted).connect(analyser);
3295
+ const spectrum = new Uint8Array(analyser.frequencyBinCount);
3296
+ const tick2 = () => {
3297
+ if (cancelled) return;
3298
+ analyser.getByteFrequencyData(spectrum);
3299
+ let sum = 0;
3300
+ for (const v of spectrum) sum += v;
3301
+ const level = sum / spectrum.length / 255;
3302
+ if (!sawSignal && level > 0.02) {
3303
+ sawSignal = true;
3304
+ onSignal(true);
3305
+ }
3306
+ for (let i = 0; i < BARS; i++) {
3307
+ const bar = barsRef.current[i];
3308
+ if (!bar) continue;
3309
+ const lit = level * BARS > i;
3310
+ bar.style.opacity = lit ? "1" : "0.25";
3311
+ }
3312
+ frame = requestAnimationFrame(tick2);
3313
+ };
3314
+ frame = requestAnimationFrame(tick2);
3315
+ }).catch(() => {
3316
+ });
3317
+ return () => {
3318
+ cancelled = true;
3319
+ if (frame) cancelAnimationFrame(frame);
3320
+ stream?.getTracks().forEach((t) => t.stop());
3321
+ void context?.close().catch(() => {
3322
+ });
3323
+ };
3324
+ }, [deviceId, onSignal]);
3325
+ return /* @__PURE__ */ jsx("div", { className: "flex items-center gap-[2px]", "aria-hidden": true, children: Array.from({ length: BARS }, (_, i) => /* @__PURE__ */ jsx(
3326
+ "span",
3327
+ {
3328
+ ref: (el) => {
3329
+ barsRef.current[i] = el;
3330
+ },
3331
+ className: "h-3.5 w-[3px] rounded-sm bg-current opacity-25 transition-opacity duration-75"
3332
+ },
3333
+ i
3334
+ )) });
3335
+ }
3336
+ function ScribeDevicePicker({ open, onOpenChange }) {
3337
+ const [devices, setDevices] = React7.useState([]);
3338
+ const [selected, setSelected] = React7.useState(null);
3339
+ const [remember, setRemember] = React7.useState(true);
3340
+ const [signals, setSignals] = React7.useState({});
3341
+ const [busy, setBusy] = React7.useState(false);
3342
+ React7.useEffect(() => {
3343
+ if (!open) return;
3344
+ let cancelled = false;
3345
+ const load2 = async () => {
3346
+ try {
3347
+ const probe = await navigator.mediaDevices.getUserMedia({ audio: true });
3348
+ probe.getTracks().forEach((t) => t.stop());
3349
+ const all = await navigator.mediaDevices.enumerateDevices();
3350
+ if (cancelled) return;
3351
+ const inputs = all.filter((d) => d.kind === "audioinput" && d.deviceId).map((d, i) => ({ deviceId: d.deviceId, label: d.label || `Microfone ${i + 1}` }));
3352
+ setDevices(inputs);
3353
+ setSelected(getActiveDeviceId() ?? inputs[0]?.deviceId ?? null);
3354
+ } catch {
3355
+ setDevices([]);
3356
+ }
3357
+ };
3358
+ void load2();
3359
+ return () => {
3360
+ cancelled = true;
3361
+ };
3362
+ }, [open]);
3363
+ const markSignal = React7.useCallback((deviceId) => {
3364
+ setSignals((prev) => prev[deviceId] ? prev : { ...prev, [deviceId]: true });
3365
+ }, []);
3366
+ const confirm = async () => {
3367
+ if (!selected) return;
3368
+ setBusy(true);
3369
+ try {
3370
+ await switchInputDevice(selected);
3371
+ setPreferredDeviceId(remember ? selected : null);
3372
+ onOpenChange(false);
3373
+ } finally {
3374
+ setBusy(false);
3375
+ }
3376
+ };
3377
+ return /* @__PURE__ */ jsx(Modal, { open, onOpenChange, children: /* @__PURE__ */ jsxs(ModalContent, { className: "max-w-md", children: [
3378
+ /* @__PURE__ */ jsx(ModalHeader, { children: /* @__PURE__ */ jsxs(ModalTitle, { className: "flex items-center gap-2", children: [
3379
+ /* @__PURE__ */ jsx(Mic, { className: "h-4 w-4" }),
3380
+ "Selecionar microfone"
3381
+ ] }) }),
3382
+ /* @__PURE__ */ jsxs(ModalBody, { className: "space-y-3", children: [
3383
+ /* @__PURE__ */ jsxs("p", { className: "text-xs text-muted-foreground", children: [
3384
+ devices.length,
3385
+ " dispositivo",
3386
+ devices.length === 1 ? "" : "s",
3387
+ " encontrado",
3388
+ devices.length === 1 ? "" : "s"
3389
+ ] }),
3390
+ /* @__PURE__ */ jsxs("div", { className: "max-h-64 space-y-1.5 overflow-y-auto", children: [
3391
+ devices.map((device) => /* @__PURE__ */ jsxs(
3392
+ "button",
3393
+ {
3394
+ type: "button",
3395
+ onClick: () => setSelected(device.deviceId),
3396
+ className: `flex w-full items-center gap-3 rounded-lg border p-2.5 text-left transition-colors ${selected === device.deviceId ? "border-primary bg-primary/5" : "border-border hover:bg-muted/50"}`,
3397
+ children: [
3398
+ /* @__PURE__ */ jsx(
3399
+ Mic,
3400
+ {
3401
+ className: `h-4 w-4 shrink-0 ${signals[device.deviceId] ? "text-primary" : "text-muted-foreground"}`
3402
+ }
3403
+ ),
3404
+ /* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
3405
+ /* @__PURE__ */ jsx("span", { className: "block truncate text-[13px] font-medium text-foreground", children: device.label }),
3406
+ /* @__PURE__ */ jsx("span", { className: "block text-[11px] text-muted-foreground", children: signals[device.deviceId] ? "Captando \xE1udio" : "Sem \xE1udio detectado" })
3407
+ ] }),
3408
+ open && /* @__PURE__ */ jsx("span", { className: "shrink-0 text-primary", children: /* @__PURE__ */ jsx(DeviceLevel, { deviceId: device.deviceId, onSignal: () => markSignal(device.deviceId) }) })
3409
+ ]
3410
+ },
3411
+ device.deviceId
3412
+ )),
3413
+ devices.length === 0 && /* @__PURE__ */ jsx("p", { className: "py-6 text-center text-xs text-muted-foreground", children: "Nenhum microfone dispon\xEDvel. Verifique a permiss\xE3o do navegador." })
3414
+ ] }),
3415
+ /* @__PURE__ */ jsxs("label", { className: "flex items-center gap-2 text-xs text-muted-foreground", children: [
3416
+ /* @__PURE__ */ jsx(Checkbox, { checked: remember, onChange: setRemember }),
3417
+ "Lembrar minha escolha"
3418
+ ] })
3419
+ ] }),
3420
+ /* @__PURE__ */ jsxs(ModalFooter, { children: [
3421
+ /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: () => onOpenChange(false), children: "Cancelar" }),
3422
+ /* @__PURE__ */ jsx(Button, { size: "sm", disabled: !selected || busy, onClick: () => void confirm(), children: "Confirmar" })
3423
+ ] })
3424
+ ] }) });
3425
+ }
3426
+ function ScribeLivePanel({ labels, onOpenSession }) {
3427
+ const state = useScribeStore((s) => s.state);
3428
+ const sessionId = useScribeStore((s) => s.sessionId);
3429
+ const subjectName = useScribeStore((s) => s.subjectName);
3430
+ const segments = useLiveTranscript((s) => s.segments);
3431
+ const [busy, setBusy] = React7.useState(false);
3432
+ const [pickerOpen, setPickerOpen] = React7.useState(false);
3433
+ const [, force] = React7.useReducer((n) => n + 1, 0);
3434
+ const live2 = state === "recording" || state === "paused" || state === "interrupted";
3435
+ React7.useEffect(() => {
3436
+ if (state !== "recording") return;
3437
+ const timer3 = setInterval(() => {
3438
+ if (document.visibilityState === "visible") force();
3439
+ }, 250);
3440
+ return () => clearInterval(timer3);
3441
+ }, [state]);
3442
+ const elapsed = live2 ? getSnapshot().elapsedMs : 0;
3443
+ const transcribed = segments.filter((s) => s.text?.trim()).length;
3444
+ const pending = live2 || segments.some((s) => !s.gap && !s.text);
3445
+ const toggle = async () => {
3446
+ setBusy(true);
3447
+ try {
3448
+ if (state === "recording") await pauseSession();
3449
+ else await resumeSession();
3450
+ } finally {
3451
+ setBusy(false);
3452
+ }
3453
+ };
3454
+ const finish = async () => {
3455
+ setBusy(true);
3456
+ try {
3457
+ const id = await endSession();
3458
+ if (id) onOpenSession?.(id);
3459
+ } finally {
3460
+ setBusy(false);
3461
+ }
3462
+ };
3463
+ if (!sessionId) {
3464
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-muted-foreground", children: [
3465
+ /* @__PURE__ */ jsx(Mic, { className: "h-5 w-5" }),
3466
+ /* @__PURE__ */ jsx("p", { className: "font-medium text-foreground", children: labels.sessionPlural }),
3467
+ /* @__PURE__ */ jsxs("p", { className: "text-xs", children: [
3468
+ "Inicie um ",
3469
+ labels.sessionSingular.toLowerCase(),
3470
+ " pela ficha do ",
3471
+ labels.subject.toLowerCase(),
3472
+ " ou pelo bot\xE3o no chat. A transcri\xE7\xE3o aparece aqui enquanto voc\xEA atende."
3473
+ ] })
3474
+ ] });
3475
+ }
3476
+ return /* @__PURE__ */ jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [
3477
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 border-b border-border/40 px-3 py-2", children: [
3478
+ /* @__PURE__ */ jsxs("span", { className: "relative inline-flex h-2 w-2 shrink-0", children: [
3479
+ state === "recording" && /* @__PURE__ */ jsx("span", { className: "absolute inline-flex h-full w-full animate-ping rounded-full bg-destructive opacity-60" }),
3480
+ /* @__PURE__ */ jsx("span", { className: "relative inline-flex h-2 w-2 rounded-full bg-destructive" })
3481
+ ] }),
3482
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
3483
+ /* @__PURE__ */ jsx("p", { className: "truncate text-[12.5px] font-medium text-foreground", children: subjectName ?? labels.sessionSingular }),
3484
+ /* @__PURE__ */ jsxs("p", { className: "text-[11px] tabular-nums text-muted-foreground", children: [
3485
+ formatElapsed(elapsed),
3486
+ transcribed > 0 && ` \xB7 ${transcribed} trecho${transcribed > 1 ? "s" : ""}`
3487
+ ] })
3488
+ ] }),
3489
+ live2 && /* @__PURE__ */ jsxs(Fragment, { children: [
3490
+ /* @__PURE__ */ jsxs(Button, { variant: "ghost", size: "sm", className: "h-7 px-2", disabled: busy, onClick: () => void toggle(), children: [
3491
+ state === "recording" ? /* @__PURE__ */ jsx(Pause, { className: "h-3.5 w-3.5" }) : /* @__PURE__ */ jsx(Play, { className: "h-3.5 w-3.5" }),
3492
+ /* @__PURE__ */ jsx("span", { className: "ml-1 text-[11px]", children: state === "recording" ? labels.pause : labels.resume })
3493
+ ] }),
3494
+ /* @__PURE__ */ jsxs(Button, { variant: "ghost", size: "sm", className: "h-7 px-2", disabled: busy, onClick: () => void finish(), children: [
3495
+ /* @__PURE__ */ jsx(Square, { className: "h-3.5 w-3.5" }),
3496
+ /* @__PURE__ */ jsx("span", { className: "ml-1 text-[11px]", children: labels.finish })
3497
+ ] }),
3498
+ /* @__PURE__ */ jsx(
3499
+ Button,
3500
+ {
3501
+ variant: "ghost",
3502
+ size: "sm",
3503
+ className: "h-7 w-7 p-0",
3504
+ title: "Selecionar microfone",
3505
+ "aria-label": "Selecionar microfone",
3506
+ onClick: () => setPickerOpen(true),
3507
+ children: /* @__PURE__ */ jsx(Settings2, { className: "h-3.5 w-3.5" })
3508
+ }
3509
+ )
3510
+ ] })
3511
+ ] }),
3512
+ /* @__PURE__ */ jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-3", children: /* @__PURE__ */ jsx(
3513
+ TranscriptPanel,
3514
+ {
3515
+ segments,
3516
+ pending,
3517
+ emptyLabel: `Nenhuma transcri\xE7\xE3o ainda neste ${labels.sessionSingular.toLowerCase()}.`
3518
+ }
3519
+ ) }),
3520
+ /* @__PURE__ */ jsx(ScribeDevicePicker, { open: pickerOpen, onOpenChange: setPickerOpen })
3521
+ ] });
3522
+ }
2766
3523
 
2767
3524
  // src/types.ts
2768
3525
  function isNarrativeSchema(schema) {
@@ -2771,9 +3528,9 @@ function isNarrativeSchema(schema) {
2771
3528
 
2772
3529
  // src/index.ts
2773
3530
  var DEFAULT_LABELS = {
2774
- sessionSingular: "Atendimento",
2775
- sessionPlural: "Atendimentos",
2776
- start: "Iniciar atendimento",
3531
+ sessionSingular: "Grava\xE7\xE3o",
3532
+ sessionPlural: "Grava\xE7\xF5es",
3533
+ start: "Gravar atendimento",
2777
3534
  pause: "Pausar",
2778
3535
  resume: "Retomar",
2779
3536
  finish: "Finalizar",
@@ -2796,18 +3553,19 @@ function createScribePlugin(options) {
2796
3553
  const generateEndpoint = options.generateEndpoint ?? "scribe-generate";
2797
3554
  const locale = options.stt.locale ?? "pt-BR";
2798
3555
  registerTranslations(scribeLocales);
3556
+ registerScribeTimelineSource();
2799
3557
  initScribeRuntime({ retention: retention2 });
2800
3558
  wireScribeStore();
2801
3559
  const openSession = (sessionId) => {
2802
3560
  window.location.hash = `/scribe/${sessionId}`;
2803
3561
  };
2804
- const ShellMount = () => React6__default.createElement(ScribeShellMount, { labels, retention: retention2, locale, onOpenSession: openSession });
3562
+ const ShellMount = () => React7__default.createElement(ScribeShellMount, { labels, retention: retention2, stt: options.stt, locale, onOpenSession: openSession });
2805
3563
  ShellMount.displayName = "ScribeShellMount";
2806
3564
  const SessionRoute = () => {
2807
3565
  const hash = typeof window !== "undefined" ? window.location.hash : "";
2808
3566
  const sessionId = hash.split("/scribe/")[1]?.split("/")[0] ?? "";
2809
3567
  if (!sessionId) return null;
2810
- return React6__default.createElement(ScribeSessionPage, {
3568
+ return React7__default.createElement(ScribeSessionPage, {
2811
3569
  sessionId,
2812
3570
  labels,
2813
3571
  presets,
@@ -2822,6 +3580,9 @@ function createScribePlugin(options) {
2822
3580
  });
2823
3581
  };
2824
3582
  SessionRoute.displayName = "ScribeSessionRoute";
3583
+ const HeaderAction = (props) => React7__default.createElement(ScribeHeaderAction, { ...props, labels });
3584
+ HeaderAction.displayName = "ScribeHeaderAction";
3585
+ const subjectEntities = options.subjectEntities ?? ["clients", "patients", "people", "persons", "customers"];
2825
3586
  return {
2826
3587
  id: "scribe",
2827
3588
  name: labels.sessionPlural,
@@ -2829,7 +3590,7 @@ function createScribePlugin(options) {
2829
3590
  version: "0.1.0",
2830
3591
  scope: options.scope ?? "universal",
2831
3592
  verticalId: options.verticalId,
2832
- defaultEnabled: false,
3593
+ defaultEnabled: options.enabledByDefault ?? false,
2833
3594
  // O scribe EMITE documento mas não é dono do conceito. plugin-forms é a
2834
3595
  // engine de documento; este é uma engine de aquisição. A dependência aponta
2835
3596
  // nessa direção e não na inversa — senão todo app que só quer uma anamnese
@@ -2851,7 +3612,16 @@ function createScribePlugin(options) {
2851
3612
  zone: "shell.topbar.end",
2852
3613
  component: ShellMount,
2853
3614
  order: -10
2854
- }
3615
+ },
3616
+ // Next to Edit on the person record. From the plugin on purpose: it only
3617
+ // renders where the engine listening for `scribe:start` is also mounted,
3618
+ // so there is no version of it that does nothing.
3619
+ ...subjectEntities.map((entity) => ({
3620
+ id: `scribe-start-${entity}`,
3621
+ zone: `${entity}.detail.header.actions`,
3622
+ component: HeaderAction,
3623
+ order: -10
3624
+ }))
2855
3625
  ],
2856
3626
  declaredFeatures: [
2857
3627
  { id: "scribe", label: labels.settingsTitle, actions: ["read", "create", "edit", "delete"] }
@@ -2905,6 +3675,6 @@ function createScribePlugin(options) {
2905
3675
  };
2906
3676
  }
2907
3677
 
2908
- export { PROMPT_VERSION, SCRIBE_START_EVENT, ScribeSessionPage, ScribeStartButton, beginSession, createScribePlugin, deleteSessionAudio, discardSession, endSession, fetchGenerations, fetchOpenSessions, fetchSegments, fetchSession, fetchSessionsForSubject, formatElapsed, formatOffset, isCaptureSupported, isNarrativeSchema, parseSections, pauseSession, renderTranscript, requestScribeStart, resumeSession, useScribeStore };
3678
+ export { PROMPT_VERSION, SCRIBE_START_EVENT, ScribeDevicePicker, ScribeLivePanel, ScribeSessionPage, ScribeStartButton, beginSession, createScribePlugin, deleteSessionAudio, discardSession, endSession, fetchGenerations, fetchOpenSessions, fetchSegments, fetchSession, fetchSessionsForSubject, formatElapsed, formatOffset, isCaptureSupported, isNarrativeSchema, parseSections, pauseSession, renderTranscript, requestScribeStart, resumeSession, useScribeStore };
2909
3679
  //# sourceMappingURL=index.js.map
2910
3680
  //# sourceMappingURL=index.js.map