@fayz-ai/plugin-scribe 0.10.0 → 0.11.1
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.
- package/dist/components/DraftPanel.d.ts +3 -1
- package/dist/components/DraftPanel.d.ts.map +1 -1
- package/dist/components/ScribeAssistantBridge.d.ts +8 -0
- package/dist/components/ScribeAssistantBridge.d.ts.map +1 -0
- package/dist/components/ScribeDevicePicker.d.ts +7 -0
- package/dist/components/ScribeDevicePicker.d.ts.map +1 -0
- package/dist/components/ScribeHeaderAction.d.ts +15 -0
- package/dist/components/ScribeHeaderAction.d.ts.map +1 -0
- package/dist/components/ScribeLivePanel.d.ts +8 -0
- package/dist/components/ScribeLivePanel.d.ts.map +1 -0
- package/dist/components/ScribeRecordingPill.d.ts +9 -1
- package/dist/components/ScribeRecordingPill.d.ts.map +1 -1
- package/dist/components/ScribeRecoveryBanner.d.ts.map +1 -1
- package/dist/components/ScribeSessionPage.d.ts +5 -1
- package/dist/components/ScribeSessionPage.d.ts.map +1 -1
- package/dist/components/ScribeShellMount.d.ts +4 -3
- package/dist/components/ScribeShellMount.d.ts.map +1 -1
- package/dist/components/TranscriptPanel.d.ts +1 -1
- package/dist/components/TranscriptPanel.d.ts.map +1 -1
- package/dist/data/supabase.d.ts +4 -12
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1203 -433
- package/dist/index.js.map +1 -1
- package/dist/lib/drain.d.ts.map +1 -1
- package/dist/lib/generate.d.ts +3 -3
- package/dist/lib/generate.d.ts.map +1 -1
- package/dist/lib/live-transcript.d.ts +13 -0
- package/dist/lib/live-transcript.d.ts.map +1 -0
- package/dist/lib/timeline-source.d.ts +9 -0
- package/dist/lib/timeline-source.d.ts.map +1 -0
- package/dist/lib/transcript-clean.d.ts +4 -0
- package/dist/lib/transcript-clean.d.ts.map +1 -0
- package/dist/lib/transcript-clean.test.d.ts +2 -0
- package/dist/lib/transcript-clean.test.d.ts.map +1 -0
- package/dist/runtime/capture.d.ts +19 -0
- package/dist/runtime/capture.d.ts.map +1 -1
- package/dist/runtime/idb.d.ts +3 -0
- package/dist/runtime/idb.d.ts.map +1 -1
- package/dist/runtime/index.d.ts +9 -0
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/pump.d.ts.map +1 -1
- package/dist/types.d.ts +14 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import * as
|
|
2
|
-
import
|
|
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,
|
|
8
|
-
import { Mic,
|
|
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
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
|
|
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] =
|
|
1086
|
+
const [isMobile, setIsMobile] = React7.useState(
|
|
978
1087
|
() => typeof window !== "undefined" && window.innerWidth < MOBILE_BREAKPOINT
|
|
979
1088
|
);
|
|
980
|
-
|
|
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] =
|
|
991
|
-
|
|
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] =
|
|
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 ?
|
|
1021
|
-
const handleToggle =
|
|
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 =
|
|
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
|
|
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
|
|
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] =
|
|
1184
|
-
|
|
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:
|
|
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
|
-
|
|
1882
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
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
|
|
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
|
-
|
|
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] =
|
|
2077
|
-
const [segments, setSegments] =
|
|
2078
|
-
const [generations, setGenerations] =
|
|
2079
|
-
const [loading, setLoading] =
|
|
2080
|
-
const [generating, setGenerating] =
|
|
2081
|
-
const [tab, setTab] =
|
|
2082
|
-
const
|
|
2083
|
-
const refresh =
|
|
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
|
-
|
|
2517
|
+
React7.useEffect(() => {
|
|
2095
2518
|
setLoading(true);
|
|
2096
2519
|
void refresh().finally(() => setLoading(false));
|
|
2097
2520
|
}, [refresh]);
|
|
2098
|
-
const pendingStt =
|
|
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
|
-
|
|
2526
|
+
React7.useEffect(() => {
|
|
2104
2527
|
if (!busy) return;
|
|
2105
2528
|
const id = setInterval(() => {
|
|
2106
2529
|
void (async () => {
|
|
2107
|
-
if (
|
|
2108
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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: "
|
|
2775
|
-
sessionPlural: "
|
|
2776
|
-
start: "
|
|
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 = () =>
|
|
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
|
|
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
|