pinecall-runtime 0.1.6__py3-none-any.whl
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.
- pinecall/__init__.py +0 -0
- pinecall/channels/__init__.py +0 -0
- pinecall/channels/_chooser.py +45 -0
- pinecall/channels/offers.py +219 -0
- pinecall/channels/rooms.py +176 -0
- pinecall/channels/routes.py +275 -0
- pinecall/channels/telephony/__init__.py +0 -0
- pinecall/channels/telephony/_twilio.py +349 -0
- pinecall/channels/telephony/carrier.py +229 -0
- pinecall/channels/telephony/carrier_catalog.py +92 -0
- pinecall/channels/telephony/carriers.csv +41 -0
- pinecall/channels/telephony/dialing.py +419 -0
- pinecall/channels/telephony/firewall.py +58 -0
- pinecall/channels/telephony/hand_over.py +78 -0
- pinecall/channels/telephony/number_path.py +176 -0
- pinecall/channels/telephony/numbers.py +530 -0
- pinecall/channels/telephony/origination.py +77 -0
- pinecall/channels/telephony/sip.py +448 -0
- pinecall/channels/whatsapp.py +413 -0
- pinecall/cli/__init__.py +0 -0
- pinecall/cli/_archive.py +85 -0
- pinecall/cli/_drift.py +38 -0
- pinecall/cli/_facts.py +67 -0
- pinecall/cli/_load.py +433 -0
- pinecall/cli/_operator.py +626 -0
- pinecall/cli/_sessions.py +164 -0
- pinecall/cli/_traceback.py +95 -0
- pinecall/cli/_usage.py +34 -0
- pinecall/cli/main.py +521 -0
- pinecall/domain/__init__.py +0 -0
- pinecall/domain/agent.py +446 -0
- pinecall/domain/call.py +152 -0
- pinecall/domain/country_codes.py +32 -0
- pinecall/domain/errors.py +151 -0
- pinecall/domain/names.py +115 -0
- pinecall/domain/org.py +120 -0
- pinecall/domain/person.py +208 -0
- pinecall/domain/scope.py +21 -0
- pinecall/evals/__init__.py +0 -0
- pinecall/evals/_evidence.py +158 -0
- pinecall/evals/calibration.py +112 -0
- pinecall/evals/callers.py +160 -0
- pinecall/evals/case.py +327 -0
- pinecall/evals/checks.py +437 -0
- pinecall/evals/compliance.py +197 -0
- pinecall/evals/dataset.py +451 -0
- pinecall/evals/goldens.py +331 -0
- pinecall/evals/judges.py +525 -0
- pinecall/evals/runs.py +252 -0
- pinecall/evals/spoken.py +356 -0
- pinecall/fleet/__init__.py +0 -0
- pinecall/fleet/client.py +569 -0
- pinecall/fleet/demand.py +51 -0
- pinecall/fleet/heartbeat.py +194 -0
- pinecall/fleet/measures.py +157 -0
- pinecall/fleet/roster.py +232 -0
- pinecall/fleet/worlds.py +44 -0
- pinecall/gateway/__init__.py +0 -0
- pinecall/gateway/_call_setup.py +53 -0
- pinecall/gateway/_deps.py +556 -0
- pinecall/gateway/_gateway.py +75 -0
- pinecall/gateway/_served.py +396 -0
- pinecall/gateway/_sockets.py +444 -0
- pinecall/gateway/_streams.py +83 -0
- pinecall/gateway/_text_calls.py +182 -0
- pinecall/gateway/api/__init__.py +0 -0
- pinecall/gateway/api/accounts.py +324 -0
- pinecall/gateway/api/agents.py +150 -0
- pinecall/gateway/api/apps.py +478 -0
- pinecall/gateway/api/box.py +658 -0
- pinecall/gateway/api/calibration.py +58 -0
- pinecall/gateway/api/callbacks.py +105 -0
- pinecall/gateway/api/calls.py +655 -0
- pinecall/gateway/api/chat.py +223 -0
- pinecall/gateway/api/dataset.py +163 -0
- pinecall/gateway/api/desk.py +113 -0
- pinecall/gateway/api/entries.py +77 -0
- pinecall/gateway/api/evals.py +647 -0
- pinecall/gateway/api/fleet.py +73 -0
- pinecall/gateway/api/hosting.py +241 -0
- pinecall/gateway/api/judges.py +97 -0
- pinecall/gateway/api/keys.py +110 -0
- pinecall/gateway/api/line.py +142 -0
- pinecall/gateway/api/members.py +197 -0
- pinecall/gateway/api/metrics.py +226 -0
- pinecall/gateway/api/numbers.py +476 -0
- pinecall/gateway/api/ops.py +428 -0
- pinecall/gateway/api/org.py +283 -0
- pinecall/gateway/api/personas.py +157 -0
- pinecall/gateway/api/pipeline.py +206 -0
- pinecall/gateway/api/prompts.py +50 -0
- pinecall/gateway/api/providers.py +240 -0
- pinecall/gateway/api/recordings.py +89 -0
- pinecall/gateway/api/relay.py +169 -0
- pinecall/gateway/api/retrieval.py +403 -0
- pinecall/gateway/api/runner.py +109 -0
- pinecall/gateway/api/sessions.py +102 -0
- pinecall/gateway/api/settings.py +463 -0
- pinecall/gateway/api/signup.py +164 -0
- pinecall/gateway/api/sso_login.py +168 -0
- pinecall/gateway/api/threads.py +187 -0
- pinecall/gateway/api/usage.py +303 -0
- pinecall/gateway/api/visitors.py +222 -0
- pinecall/gateway/api/whatsapp.py +48 -0
- pinecall/gateway/api/widget.py +49 -0
- pinecall/gateway/app.py +504 -0
- pinecall/gateway/calls/__init__.py +0 -0
- pinecall/gateway/calls/binding.py +63 -0
- pinecall/gateway/calls/commands.py +18 -0
- pinecall/gateway/calls/inbox.py +100 -0
- pinecall/gateway/calls/known.py +23 -0
- pinecall/gateway/calls/owners.py +87 -0
- pinecall/gateway/calls/pump.py +33 -0
- pinecall/gateway/calls/serving.py +118 -0
- pinecall/gateway/calls/threads.py +367 -0
- pinecall/gateway/dispatching/__init__.py +0 -0
- pinecall/gateway/dispatching/arrivals.py +58 -0
- pinecall/gateway/dispatching/sweep.py +43 -0
- pinecall/gateway/ending/__init__.py +0 -0
- pinecall/gateway/ending/reaper.py +160 -0
- pinecall/gateway/ending/seal.py +312 -0
- pinecall/gateway/ending/stranded.py +117 -0
- pinecall/log/__init__.py +0 -0
- pinecall/log/_histogram.py +74 -0
- pinecall/log/_relay.py +259 -0
- pinecall/log/_writer.py +454 -0
- pinecall/log/days.py +154 -0
- pinecall/log/drift.py +550 -0
- pinecall/log/facts.py +358 -0
- pinecall/log/inbox.py +169 -0
- pinecall/log/lists.py +155 -0
- pinecall/log/logs.py +581 -0
- pinecall/log/openings.py +71 -0
- pinecall/log/private.py +135 -0
- pinecall/log/queries.py +229 -0
- pinecall/log/readers.py +207 -0
- pinecall/log/reduce.py +659 -0
- pinecall/log/refold.py +217 -0
- pinecall/log/store.py +370 -0
- pinecall/log/usage.py +242 -0
- pinecall/postgres/__init__.py +0 -0
- pinecall/postgres/migrate.py +202 -0
- pinecall/postgres/migrations/0001_schema.sql +591 -0
- pinecall/postgres/migrations/0002_worlds.sql +15 -0
- pinecall/postgres/migrations/0003_lent.sql +4 -0
- pinecall/postgres/migrations/0004_carriers.sql +23 -0
- pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
- pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
- pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
- pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
- pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
- pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
- pinecall/postgres/migrations/0011_erasure.sql +32 -0
- pinecall/postgres/migrations/0012_org_policy.sql +10 -0
- pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
- pinecall/postgres/migrations/0014_consents.sql +24 -0
- pinecall/postgres/migrations/0015_disclosure.sql +7 -0
- pinecall/postgres/migrations/0016_call_records.sql +21 -0
- pinecall/postgres/migrations/0017_reads.sql +17 -0
- pinecall/postgres/migrations/0018_hosting.sql +41 -0
- pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
- pinecall/postgres/migrations/0020_written_once.sql +7 -0
- pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
- pinecall/postgres/migrations/0022_sealed.sql +39 -0
- pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
- pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
- pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
- pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
- pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
- pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
- pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
- pinecall/postgres/migrations/0060_drift.sql +62 -0
- pinecall/postgres/migrations/0061_canaries.sql +24 -0
- pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
- pinecall/postgres/migrations/0063_prompts.sql +16 -0
- pinecall/postgres/migrations/0075_call_private.sql +14 -0
- pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
- pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
- pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
- pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
- pinecall/postgres/migrations/0080_call_openings.sql +20 -0
- pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
- pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
- pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
- pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
- pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
- pinecall/postgres/migrations/0086_route_origin.sql +17 -0
- pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
- pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
- pinecall/postgres/migrations/0089_offers.sql +21 -0
- pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
- pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
- pinecall/postgres/migrations/0092_key_parent.sql +6 -0
- pinecall/postgres/migrations/0093_code_tries.sql +10 -0
- pinecall/postgres/migrations/0094_route_approval.sql +12 -0
- pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
- pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
- pinecall/postgres/migrations/0097_case_status.sql +19 -0
- pinecall/postgres/pool.py +190 -0
- pinecall/process/__init__.py +0 -0
- pinecall/process/_objects.py +97 -0
- pinecall/process/box_settings.py +23 -0
- pinecall/process/connections.py +124 -0
- pinecall/process/metrics.py +127 -0
- pinecall/process/recordings.py +323 -0
- pinecall/process/resolver.py +41 -0
- pinecall/process/sealed_audio.py +173 -0
- pinecall/process/settings.py +534 -0
- pinecall/process/shared.py +158 -0
- pinecall/process/signal.py +416 -0
- pinecall/providers/__init__.py +0 -0
- pinecall/providers/build.py +570 -0
- pinecall/providers/catalog.py +266 -0
- pinecall/providers/credentials.py +230 -0
- pinecall/providers/declared.py +69 -0
- pinecall/providers/prices.py +240 -0
- pinecall/providers/tried.py +104 -0
- pinecall/providers/voices.py +174 -0
- pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
- pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
- pinecall/public/console/favicon.png +0 -0
- pinecall/public/console/index.html +20 -0
- pinecall/public/console/logo-mark.png +0 -0
- pinecall/public/console/pinecall-logo-dark.png +0 -0
- pinecall/public/console/pinecall-logo.png +0 -0
- pinecall/public/console/pinecall-mark.png +0 -0
- pinecall/public/console/sw.js +37 -0
- pinecall/public/widget/pinecall-widget.js +853 -0
- pinecall/py.typed +0 -0
- pinecall/retrieval/__init__.py +0 -0
- pinecall/retrieval/_search.py +186 -0
- pinecall/retrieval/_sections.py +70 -0
- pinecall/retrieval/embed.py +340 -0
- pinecall/retrieval/extraction.py +403 -0
- pinecall/retrieval/knowledge.py +659 -0
- pinecall/retrieval/lookups.py +192 -0
- pinecall/retrieval/memory.py +603 -0
- pinecall/runner/__init__.py +0 -0
- pinecall/runner/_kube.py +459 -0
- pinecall/runner/_plan.py +157 -0
- pinecall/runner/main.py +306 -0
- pinecall/session/__init__.py +0 -0
- pinecall/session/_agent.py +141 -0
- pinecall/session/_hearing.py +101 -0
- pinecall/session/_livekit.py +195 -0
- pinecall/session/_prompt.py +147 -0
- pinecall/session/_recording.py +237 -0
- pinecall/session/call.py +367 -0
- pinecall/session/clock.py +40 -0
- pinecall/session/hold.py +239 -0
- pinecall/session/room.py +626 -0
- pinecall/session/session.py +690 -0
- pinecall/session/text.py +144 -0
- pinecall/session/tools.py +483 -0
- pinecall/session/voice.py +118 -0
- pinecall/session/widget.py +201 -0
- pinecall/tenancy/__init__.py +0 -0
- pinecall/tenancy/admission.py +204 -0
- pinecall/tenancy/agents.py +164 -0
- pinecall/tenancy/canary.py +126 -0
- pinecall/tenancy/carrier_networks.py +156 -0
- pinecall/tenancy/carriers.py +265 -0
- pinecall/tenancy/codes.py +196 -0
- pinecall/tenancy/consents.py +167 -0
- pinecall/tenancy/dial_policy.py +405 -0
- pinecall/tenancy/disclosure.py +41 -0
- pinecall/tenancy/erasure.py +204 -0
- pinecall/tenancy/export.py +167 -0
- pinecall/tenancy/hosted_running.py +166 -0
- pinecall/tenancy/hosting.py +475 -0
- pinecall/tenancy/judges.py +126 -0
- pinecall/tenancy/keys.py +467 -0
- pinecall/tenancy/knocks.py +40 -0
- pinecall/tenancy/letters.py +378 -0
- pinecall/tenancy/mail.py +458 -0
- pinecall/tenancy/org_secrets.py +123 -0
- pinecall/tenancy/orgs.py +78 -0
- pinecall/tenancy/people.py +543 -0
- pinecall/tenancy/personas.py +145 -0
- pinecall/tenancy/policy.py +65 -0
- pinecall/tenancy/prompts.py +48 -0
- pinecall/tenancy/reads.py +73 -0
- pinecall/tenancy/recording_keys.py +41 -0
- pinecall/tenancy/remembered.py +148 -0
- pinecall/tenancy/retention.py +86 -0
- pinecall/tenancy/scopes.py +406 -0
- pinecall/tenancy/signin.py +622 -0
- pinecall/tenancy/spend.py +69 -0
- pinecall/tenancy/sso.py +431 -0
- pinecall/tenancy/sso_domains.py +132 -0
- pinecall/tenancy/throttle.py +88 -0
- pinecall/tenancy/tokens.py +400 -0
- pinecall/tenancy/traceback.py +70 -0
- pinecall/tenancy/usage.py +136 -0
- pinecall/tenancy/vault.py +204 -0
- pinecall/tenancy/words.py +144 -0
- pinecall/wire/__init__.py +0 -0
- pinecall/wire/commands.py +350 -0
- pinecall/wire/events.py +655 -0
- pinecall/wire/frames.py +65 -0
- pinecall/wire/metrics.py +287 -0
- pinecall/wire/parts.py +320 -0
- pinecall/wire/rest/__init__.py +0 -0
- pinecall/wire/rest/accounts.py +512 -0
- pinecall/wire/rest/agents.py +172 -0
- pinecall/wire/rest/calls.py +397 -0
- pinecall/wire/rest/evals.py +453 -0
- pinecall/wire/rest/fleet.py +69 -0
- pinecall/wire/rest/hosting.py +174 -0
- pinecall/wire/rest/numbers.py +280 -0
- pinecall/wire/rest/ops.py +342 -0
- pinecall/wire/rest/providers.py +83 -0
- pinecall/wire/rest/retrieval.py +301 -0
- pinecall/wire/rest/settings.py +184 -0
- pinecall/wire/rest/usage.py +245 -0
- pinecall/wire/room.py +61 -0
- pinecall/wire/scores.py +49 -0
- pinecall/wire/state.py +243 -0
- pinecall/worker/__init__.py +0 -0
- pinecall/worker/_job.py +655 -0
- pinecall/worker/_recorder.py +82 -0
- pinecall/worker/_traces.py +48 -0
- pinecall/worker/main.py +307 -0
- pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
- pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
- pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
- pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
- pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
// <pinecall-widget>: a floating button that connects a visitor to a Pinecall agent by phone,
|
|
2
|
+
// browser voice call, or chat.
|
|
3
|
+
//
|
|
4
|
+
// The page's own server mints visit tokens and relays the call log with the org's API key, so
|
|
5
|
+
// the key never reaches the browser. The same endpoints serve every agent of the org.
|
|
6
|
+
//
|
|
7
|
+
// <script type="module" src="/pinecall/pinecall-widget.js"></script>
|
|
8
|
+
// <pinecall-widget agent="bidfire-sales" name="Sam" company="BidFire"
|
|
9
|
+
// token-url="/pinecall/token.php" log-url="/pinecall/log.php"></pinecall-widget>
|
|
10
|
+
//
|
|
11
|
+
// Attributes:
|
|
12
|
+
// agent required · the agent's slug
|
|
13
|
+
// token-url required · mints visit tokens (POST {agent, scope}). Alternatively set the
|
|
14
|
+
// `tokenProvider` property to (scope, agent) => Promise<token JSON>
|
|
15
|
+
// log-url optional · lists phone calls and relays a call's log (GET ?agent=&list=1,
|
|
16
|
+
// GET ?agent=&call=). Enables log-based transcripts, the live phone call under
|
|
17
|
+
// "Call us" and the tools toggle; without it, text comes from LiveKit text streams
|
|
18
|
+
// code-url optional · requests a call code (POST {agent}); your server calls the gateway's
|
|
19
|
+
// POST /v1/codes with the org key. Or set `codeProvider` to (agent) => Promise<code
|
|
20
|
+
// JSON>. "Call us" then shows the number and a 4-digit code, and follows only the
|
|
21
|
+
// call that enters it; no log-url needed
|
|
22
|
+
// gateway optional · where codes and call logs are read; defaults to this file's origin,
|
|
23
|
+
// or cloud.pinecall.io when served from jsDelivr
|
|
24
|
+
// phone optional · number for "Call us" without a code; with neither, "Call us" is hidden
|
|
25
|
+
// name optional · display name, "Assistant" by default
|
|
26
|
+
// company optional · shown under the name
|
|
27
|
+
// tagline optional · line under the name, e.g. "books your technician"
|
|
28
|
+
// label optional · button text, "Talk to <name>" by default
|
|
29
|
+
// greeting optional · plain-text first message, shown before any call starts
|
|
30
|
+
// autostart optional · open straight into a voice call; the microphone is requested in the
|
|
31
|
+
// same click that opens the panel
|
|
32
|
+
// position optional · bottom-right (default), bottom-left, top-right, top-left, or inline
|
|
33
|
+
// (rendered where the tag is, in the page flow)
|
|
34
|
+
// theme optional · auto (default, follows the browser), light, or dark
|
|
35
|
+
//
|
|
36
|
+
// Styling: the font is inherited. These custom properties apply in both themes: --pc-accent,
|
|
37
|
+
// --pc-accent-soft, --pc-ink, --pc-muted, --pc-line, --pc-radius, --pc-offset, --pc-font,
|
|
38
|
+
// --pc-font-size, --pc-z. Parts: `button` and `panel`, e.g. pinecall-widget::part(button).
|
|
39
|
+
//
|
|
40
|
+
// Events on the tag: pinecall:open, pinecall:started {call, scope}, pinecall:ended {call}.
|
|
41
|
+
//
|
|
42
|
+
// Uses LiveKit's client SDK from a CDN; no build step.
|
|
43
|
+
|
|
44
|
+
import { ParticipantKind, Room, RoomEvent, Track } from "https://cdn.jsdelivr.net/npm/livekit-client@2/+esm";
|
|
45
|
+
|
|
46
|
+
const TRANSCRIPTION = "lk.transcription";
|
|
47
|
+
const CHAT = "lk.chat";
|
|
48
|
+
const SEGMENT_ID = "lk.segment_id";
|
|
49
|
+
const TRANSCRIPTION_FINAL = "lk.transcription_final";
|
|
50
|
+
const TOOLS_SETTING = "pinecall-widget:show-tools";
|
|
51
|
+
// Default gateway: the origin that served this file, or Pinecall's when served from the CDN.
|
|
52
|
+
const SERVED_FROM = new URL(import.meta.url).origin;
|
|
53
|
+
const A_GATEWAY = SERVED_FROM.includes("jsdelivr.net") ? "https://cloud.pinecall.io" : SERVED_FROM;
|
|
54
|
+
// Must match the entry types a log-url relay requests.
|
|
55
|
+
const LOG_TYPES = "call.started,user.transcript,agent.transcript,turn.user,turn.agent,tool.call,tool.result,call.ended";
|
|
56
|
+
|
|
57
|
+
/** Format a NANP number as +1 XXX XXX XXXX; other numbers are returned unchanged. */
|
|
58
|
+
function spaced(number) {
|
|
59
|
+
const nanp = /^\+1(\d{3})(\d{3})(\d{4})$/.exec(number);
|
|
60
|
+
return nanp ? `+1 ${nanp[1]} ${nanp[2]} ${nanp[3]}` : number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Dark palette for `theme="dark"` and for `auto` under prefers-color-scheme: dark. Page-set custom
|
|
64
|
+
// properties still take precedence.
|
|
65
|
+
const DARK = `
|
|
66
|
+
color-scheme: dark;
|
|
67
|
+
color: var(--pc-ink, #e5e7eb);
|
|
68
|
+
--_soft: var(--pc-accent-soft, color-mix(in srgb, var(--_accent) 22%, #17181c));
|
|
69
|
+
--_accent-ink: color-mix(in srgb, var(--_accent) 55%, #fff);
|
|
70
|
+
--_line: var(--pc-line, #2c2f36);
|
|
71
|
+
--_muted: var(--pc-muted, #9ca3af);
|
|
72
|
+
--_surface: #17181c;
|
|
73
|
+
--_raised: #24262c;
|
|
74
|
+
--_field: #3a3d45;
|
|
75
|
+
--_live: #4ade80;
|
|
76
|
+
--_failed: #f87171;
|
|
77
|
+
--_danger-line: #7f1d1d;
|
|
78
|
+
--_shadow: rgba(0, 0, 0, .5);
|
|
79
|
+
`;
|
|
80
|
+
|
|
81
|
+
const STYLE = `
|
|
82
|
+
:host {
|
|
83
|
+
all: initial;
|
|
84
|
+
display: contents;
|
|
85
|
+
color-scheme: light;
|
|
86
|
+
font-family: var(--pc-font, inherit);
|
|
87
|
+
font-size: var(--pc-font-size, 15px);
|
|
88
|
+
line-height: 1.45;
|
|
89
|
+
color: var(--pc-ink, #1f2937);
|
|
90
|
+
--_accent: var(--pc-accent, #6d28d9);
|
|
91
|
+
--_accent-ink: var(--_accent);
|
|
92
|
+
--_soft: var(--pc-accent-soft, #f5f3ff);
|
|
93
|
+
--_line: var(--pc-line, #e5e7eb);
|
|
94
|
+
--_muted: var(--pc-muted, #6b7280);
|
|
95
|
+
--_surface: #fff;
|
|
96
|
+
--_raised: #f3f4f6;
|
|
97
|
+
--_field: #d1d5db;
|
|
98
|
+
--_live: #16a34a;
|
|
99
|
+
--_failed: #dc2626;
|
|
100
|
+
--_danger-line: #fecaca;
|
|
101
|
+
--_shadow: rgba(17, 24, 39, .18);
|
|
102
|
+
--_radius: var(--pc-radius, 14px);
|
|
103
|
+
--_offset: var(--pc-offset, 20px);
|
|
104
|
+
--_z: var(--pc-z, 2147483000);
|
|
105
|
+
}
|
|
106
|
+
:host([theme="dark"]) { ${DARK} }
|
|
107
|
+
@media (prefers-color-scheme: dark) { :host(:not([theme="light"])) { ${DARK} } }
|
|
108
|
+
:host([position="inline"]) { display: inline-block; }
|
|
109
|
+
* { box-sizing: border-box; }
|
|
110
|
+
.fab { position: fixed; right: var(--_offset); bottom: var(--_offset); z-index: var(--_z); display: inline-flex; align-items: center; gap: 8px;
|
|
111
|
+
padding: 11px 16px; border: 0; border-radius: 999px; cursor: pointer; color: #fff; font: inherit; font-weight: 500;
|
|
112
|
+
background: var(--_accent); box-shadow: 0 6px 18px var(--_shadow); transition: transform .12s, box-shadow .12s; }
|
|
113
|
+
.fab:hover { transform: translateY(-1px); box-shadow: 0 8px 22px var(--_shadow); }
|
|
114
|
+
.fab svg { width: 18px; height: 18px; }
|
|
115
|
+
.panel { position: fixed; right: var(--_offset); bottom: calc(var(--_offset) + 60px); z-index: var(--_z); width: min(380px, calc(100vw - 2 * var(--_offset)));
|
|
116
|
+
height: auto; max-height: min(640px, calc(100vh - 110px)); display: flex; flex-direction: column; background: var(--_surface); color: inherit;
|
|
117
|
+
border-radius: var(--_radius); overflow: hidden; box-shadow: 0 16px 40px var(--_shadow); border: 1px solid var(--_line); }
|
|
118
|
+
.panel.wide { width: min(480px, calc(100vw - 2 * var(--_offset))); height: min(760px, calc(100vh - 110px)); }
|
|
119
|
+
.panel[hidden] { display: none; }
|
|
120
|
+
:host([position="bottom-left"]) .fab, :host([position="bottom-left"]) .panel { right: auto; left: var(--_offset); }
|
|
121
|
+
:host([position="top-right"]) .fab { bottom: auto; top: var(--_offset); }
|
|
122
|
+
:host([position="top-right"]) .panel { bottom: auto; top: calc(var(--_offset) + 60px); }
|
|
123
|
+
:host([position="top-left"]) .fab { right: auto; bottom: auto; left: var(--_offset); top: var(--_offset); }
|
|
124
|
+
:host([position="top-left"]) .panel { right: auto; bottom: auto; left: var(--_offset); top: calc(var(--_offset) + 60px); }
|
|
125
|
+
:host([position="inline"]) .fab { position: static; }
|
|
126
|
+
@media (max-width: 520px) {
|
|
127
|
+
.panel.wide { right: 0; bottom: 0; left: 0; top: auto; width: 100vw; height: 100vh; max-height: 100vh; border-radius: 0; }
|
|
128
|
+
}
|
|
129
|
+
header { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; flex: 0 0 auto;
|
|
130
|
+
border-bottom: 1px solid var(--_line); }
|
|
131
|
+
header h2 { margin: 0; font-size: 16px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
|
132
|
+
header h2::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--_accent); }
|
|
133
|
+
header p { margin: 2px 0 0; font-size: 13px; color: var(--_muted); }
|
|
134
|
+
header button { border: 0; background: transparent; color: var(--_muted); font-size: 22px; cursor: pointer; line-height: 1; padding: 4px; }
|
|
135
|
+
header button:hover { color: inherit; }
|
|
136
|
+
.body { padding: 16px 18px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; flex: 1 1 auto; min-height: 0; }
|
|
137
|
+
.option { display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 14px; text-align: left; cursor: pointer;
|
|
138
|
+
border: 1px solid var(--_line); border-radius: calc(var(--_radius) - 2px); background: var(--_surface); color: inherit; font: inherit; text-decoration: none; }
|
|
139
|
+
.option:hover { border-color: var(--_accent); background: var(--_soft); }
|
|
140
|
+
.option .icon { width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; background: var(--_soft); color: var(--_accent-ink); flex: 0 0 36px; }
|
|
141
|
+
.option .icon svg { width: 18px; height: 18px; }
|
|
142
|
+
.option b { display: block; font-weight: 500; }
|
|
143
|
+
.option span { display: block; font-size: 13px; color: var(--_muted); }
|
|
144
|
+
.number { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; border-radius: calc(var(--_radius) - 2px); background: var(--_soft); }
|
|
145
|
+
.number b { font-size: 20px; font-weight: 500; letter-spacing: .01em; }
|
|
146
|
+
.number a { font: inherit; font-weight: 500; padding: 9px 14px; border-radius: 10px; background: var(--_accent); color: #fff; text-decoration: none; }
|
|
147
|
+
.dial { display: flex; flex-direction: column; gap: 14px; flex: 0 0 auto; }
|
|
148
|
+
.step { display: grid; grid-template-columns: 26px 1fr; gap: 4px 12px; align-items: start; }
|
|
149
|
+
.step .n { grid-row: span 2; width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; font-size: 13px; font-weight: 600;
|
|
150
|
+
background: var(--_soft); color: var(--_accent-ink); }
|
|
151
|
+
.step .say { font-size: 14px; font-weight: 500; line-height: 26px; }
|
|
152
|
+
.step .say small { font-weight: 400; color: var(--_muted); font-size: 13px; }
|
|
153
|
+
.step .number { padding: 10px 10px 10px 14px; }
|
|
154
|
+
.number a { display: inline-flex; align-items: center; gap: 6px; }
|
|
155
|
+
.number a svg { width: 15px; height: 15px; }
|
|
156
|
+
.digits { display: flex; gap: 8px; }
|
|
157
|
+
.digits span { width: 52px; height: 60px; display: grid; place-items: center; font-size: 30px; font-weight: 600; font-variant-numeric: tabular-nums;
|
|
158
|
+
border: 1.5px solid color-mix(in srgb, var(--_accent) 30%, var(--_line)); border-radius: 12px; background: var(--_surface); }
|
|
159
|
+
.waiting { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 2px; }
|
|
160
|
+
.waiting .status { display: inline-flex; align-items: center; gap: 8px; }
|
|
161
|
+
.waiting .status:not(.failed)::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--_accent); animation: pc-pulse 1.4s ease-in-out infinite; }
|
|
162
|
+
.expires { font-size: 12px; color: var(--_muted); padding: 3px 9px; border-radius: 999px; background: var(--_raised); font-variant-numeric: tabular-nums; }
|
|
163
|
+
.soon { display: grid; place-items: center; text-align: center; padding: 18px; font-size: 13px; color: var(--_muted);
|
|
164
|
+
border: 1.5px dashed var(--_line); border-radius: calc(var(--_radius) - 2px); flex: 1 1 auto; min-height: 110px; }
|
|
165
|
+
@keyframes pc-pulse { 0%, 100% { opacity: .35; transform: scale(.85); } 50% { opacity: 1; transform: scale(1); } }
|
|
166
|
+
@media (prefers-reduced-motion: reduce) { .waiting .status::before { animation: none; } }
|
|
167
|
+
.lines { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; flex: 1 1 auto; overflow-y: auto; min-height: 120px; }
|
|
168
|
+
.lines li { padding: 8px 12px; border-radius: 12px; max-width: 88%; font-size: 14px; }
|
|
169
|
+
.lines li.agent { background: var(--_soft); align-self: flex-start; border-bottom-left-radius: 4px; }
|
|
170
|
+
.lines li.user { background: var(--_raised); align-self: flex-end; border-bottom-right-radius: 4px; }
|
|
171
|
+
.lines li.interim { opacity: .55; }
|
|
172
|
+
.lines li.mark { background: none; color: var(--_muted); font: 12px/1.4 ui-monospace, Menlo, monospace; max-width: 100%; padding: 2px 8px; }
|
|
173
|
+
.lines li.mark[hidden] { display: none; }
|
|
174
|
+
.status { font-size: 13px; color: var(--_muted); }
|
|
175
|
+
.status.live { color: var(--_live); }
|
|
176
|
+
.status.failed { color: var(--_failed); }
|
|
177
|
+
.row { display: flex; gap: 8px; flex: 0 0 auto; }
|
|
178
|
+
input[type=text] { flex: 1; font: inherit; padding: 10px 12px; border: 1px solid var(--_field); border-radius: 10px; outline: none; color: inherit; background: var(--_surface); }
|
|
179
|
+
input[type=text]:focus { border-color: var(--_accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--_accent) 20%, transparent); }
|
|
180
|
+
.btn { font: inherit; font-weight: 500; padding: 9px 14px; border: 0; border-radius: 10px; cursor: pointer; background: var(--_accent); color: #fff; }
|
|
181
|
+
.btn.quiet { background: var(--_raised); color: inherit; }
|
|
182
|
+
.btn.danger { background: var(--_surface); color: var(--_failed); border: 1px solid var(--_danger-line); }
|
|
183
|
+
.btn[disabled] { opacity: .5; cursor: default; }
|
|
184
|
+
footer { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 12px 18px; border-top: 1px solid var(--_line); flex: 0 0 auto; }
|
|
185
|
+
footer .right { display: flex; align-items: center; gap: 12px; }
|
|
186
|
+
.back { border: 0; background: transparent; color: var(--_accent-ink); font: inherit; cursor: pointer; padding: 0; }
|
|
187
|
+
.toggle { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--_muted); cursor: pointer; user-select: none; }
|
|
188
|
+
.toggle input { accent-color: var(--_accent); }
|
|
189
|
+
.greeting { margin: 0; padding: 10px 14px; border-radius: 12px; border-bottom-left-radius: 4px; align-self: flex-start;
|
|
190
|
+
max-width: 88%; font-size: 14px; background: var(--_soft); white-space: pre-line; }
|
|
191
|
+
`;
|
|
192
|
+
|
|
193
|
+
const ICONS = {
|
|
194
|
+
spark: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v3m0 12v3M3 12h3m12 0h3M5.6 5.6l2.1 2.1m8.6 8.6 2.1 2.1M5.6 18.4l2.1-2.1m8.6-8.6 2.1-2.1"/><circle cx="12" cy="12" r="3"/></svg>`,
|
|
195
|
+
phone: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2"/></svg>`,
|
|
196
|
+
mic: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/></svg>`,
|
|
197
|
+
chat: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 5h16v11H8l-4 4z"/></svg>`,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Renders one call's log as a transcript: caller turns, agent turns and tool calls in log order.
|
|
202
|
+
* The tools toggle shows or hides tool entries without redrawing.
|
|
203
|
+
*/
|
|
204
|
+
class Transcript {
|
|
205
|
+
constructor(list, showTools) {
|
|
206
|
+
this.list = list;
|
|
207
|
+
this.showTools = showTools;
|
|
208
|
+
this.stream = null;
|
|
209
|
+
this.lines = new Map();
|
|
210
|
+
this.hearing = null;
|
|
211
|
+
this.seen = 0;
|
|
212
|
+
this.onAgent = null;
|
|
213
|
+
this.onEnd = null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
follow(url, attempt = 0) {
|
|
217
|
+
this.stop();
|
|
218
|
+
if (attempt === 0) {
|
|
219
|
+
this.list.replaceChildren();
|
|
220
|
+
this.lines.clear();
|
|
221
|
+
this.hearing = null;
|
|
222
|
+
this.seen = 0;
|
|
223
|
+
}
|
|
224
|
+
const stream = new EventSource(url);
|
|
225
|
+
this.stream = stream;
|
|
226
|
+
// A new EventSource has no Last-Event-ID, so a reconnect replays the log from the start;
|
|
227
|
+
// skip entries by seq to avoid drawing them twice.
|
|
228
|
+
const on = (type, draw) =>
|
|
229
|
+
stream.addEventListener(type, (event) => {
|
|
230
|
+
// Not `lastEventId`: an event without an id inherits the previous one's.
|
|
231
|
+
const seq = JSON.parse(event.data).seq;
|
|
232
|
+
if (typeof seq === "number") {
|
|
233
|
+
if (seq <= this.seen) return;
|
|
234
|
+
this.seen = seq;
|
|
235
|
+
}
|
|
236
|
+
draw(event);
|
|
237
|
+
});
|
|
238
|
+
const data = (event) => JSON.parse(event.data).data ?? {};
|
|
239
|
+
stream.onerror = () => {
|
|
240
|
+
if (stream.readyState !== EventSource.CLOSED) return;
|
|
241
|
+
stream.close();
|
|
242
|
+
if (this.stream === stream && attempt < 10) setTimeout(() => this.follow(url, attempt + 1), 1000);
|
|
243
|
+
};
|
|
244
|
+
// Settle the line on the final transcript; `turn.user` arrives later, after end-of-utterance.
|
|
245
|
+
on("user.transcript", (event) => {
|
|
246
|
+
const heard = data(event);
|
|
247
|
+
if (!this.hearing) this.hearing = this.line("user interim", "");
|
|
248
|
+
this.hearing.textContent = heard.text ?? "";
|
|
249
|
+
if (heard.final) this.hearing.className = "user";
|
|
250
|
+
this.scroll();
|
|
251
|
+
});
|
|
252
|
+
on("turn.user", (event) => {
|
|
253
|
+
const item = this.hearing ?? this.line("user", "");
|
|
254
|
+
item.className = "user";
|
|
255
|
+
item.textContent = data(event).text ?? "";
|
|
256
|
+
this.hearing = null;
|
|
257
|
+
});
|
|
258
|
+
on("agent.transcript", (event) => {
|
|
259
|
+
const word = data(event);
|
|
260
|
+
this.spoken(`speech_${word.speech_id}`, word.text ?? "", word.start);
|
|
261
|
+
this.onAgent?.();
|
|
262
|
+
});
|
|
263
|
+
on("turn.agent", (event) => {
|
|
264
|
+
const turn = data(event);
|
|
265
|
+
this.said(`speech_${turn.speech_id}`, turn.text ?? "", true);
|
|
266
|
+
this.onAgent?.();
|
|
267
|
+
});
|
|
268
|
+
on("tool.call", (event) => {
|
|
269
|
+
const tool = data(event);
|
|
270
|
+
this.mark(`→ ${tool.name}(${JSON.stringify(tool.arguments ?? {})})`);
|
|
271
|
+
});
|
|
272
|
+
on("tool.result", (event) => {
|
|
273
|
+
const result = data(event);
|
|
274
|
+
this.mark(`← ${result.name}: ${result.error ?? JSON.stringify(result.output ?? "").slice(0, 120)}`);
|
|
275
|
+
});
|
|
276
|
+
on("call.ended", (event) => {
|
|
277
|
+
this.mark(`— ended: ${data(event).reason ?? ""}`, true);
|
|
278
|
+
this.stop();
|
|
279
|
+
this.onEnd?.();
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Show typed text immediately, greyed until the log's `turn.user` confirms it. */
|
|
284
|
+
typed(text) {
|
|
285
|
+
this.hearing = this.line("user interim", text);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Remove the pending typed line after a failed send. */
|
|
289
|
+
untyped() {
|
|
290
|
+
this.hearing?.remove();
|
|
291
|
+
this.hearing = null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
stop() {
|
|
295
|
+
this.stream?.close();
|
|
296
|
+
this.stream = null;
|
|
297
|
+
for (const item of this.lines.values()) item.pace?.timers.forEach(clearTimeout);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Show one agent word in sync with playback. Words arrive before they play, with a start time
|
|
302
|
+
* relative to the speech; that offset is counted from the first word's arrival. Untimed words
|
|
303
|
+
* show immediately.
|
|
304
|
+
*/
|
|
305
|
+
spoken(key, text, start) {
|
|
306
|
+
let item = this.lines.get(key);
|
|
307
|
+
if (!item) {
|
|
308
|
+
item = this.line("agent interim", "");
|
|
309
|
+
this.lines.set(key, item);
|
|
310
|
+
item.pace = { anchor: performance.now(), last: 0, timers: [] };
|
|
311
|
+
}
|
|
312
|
+
const pace = item.pace;
|
|
313
|
+
const show = () => {
|
|
314
|
+
item.textContent += text;
|
|
315
|
+
this.scroll();
|
|
316
|
+
};
|
|
317
|
+
const due = typeof start === "number" ? Math.max(pace.anchor + start * 1000, pace.last) : performance.now();
|
|
318
|
+
pace.last = due;
|
|
319
|
+
const wait = due - performance.now();
|
|
320
|
+
if (wait <= 0) show();
|
|
321
|
+
else pace.timers.push(setTimeout(show, wait));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Replace the pending words with the final text of the agent's turn. */
|
|
325
|
+
said(key, text, final) {
|
|
326
|
+
let item = this.lines.get(key);
|
|
327
|
+
if (!item) {
|
|
328
|
+
item = this.line("agent", "");
|
|
329
|
+
this.lines.set(key, item);
|
|
330
|
+
}
|
|
331
|
+
item.pace?.timers.forEach(clearTimeout);
|
|
332
|
+
item.pace = null;
|
|
333
|
+
item.className = `agent${final ? "" : " interim"}`;
|
|
334
|
+
item.textContent = text;
|
|
335
|
+
this.scroll();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
mark(text, always = false) {
|
|
339
|
+
const item = this.line("mark", text);
|
|
340
|
+
if (!always) {
|
|
341
|
+
item.dataset.tool = "1";
|
|
342
|
+
item.hidden = !this.showTools();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
line(className, text) {
|
|
347
|
+
const item = document.createElement("li");
|
|
348
|
+
item.className = className;
|
|
349
|
+
item.textContent = text;
|
|
350
|
+
this.list.append(item);
|
|
351
|
+
this.scroll();
|
|
352
|
+
return item;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
tools(shown) {
|
|
356
|
+
for (const item of this.list.querySelectorAll("li[data-tool]")) item.hidden = !shown;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
scroll() {
|
|
360
|
+
this.list.scrollTop = this.list.scrollHeight;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
class PinecallWidget extends HTMLElement {
|
|
365
|
+
constructor() {
|
|
366
|
+
super();
|
|
367
|
+
this.attachShadow({ mode: "open" });
|
|
368
|
+
this.room = null;
|
|
369
|
+
this.streamLines = new Map();
|
|
370
|
+
this.transcript = null;
|
|
371
|
+
this.phonePoll = null;
|
|
372
|
+
this.codeWait = null;
|
|
373
|
+
this.codeClock = null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** The log-url with this agent's query appended. */
|
|
377
|
+
log(query) {
|
|
378
|
+
return `${this.logUrl}${this.logUrl.includes("?") ? "&" : "?"}agent=${encodeURIComponent(this.agent)}&${query}`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
connectedCallback() {
|
|
382
|
+
this.agent = this.getAttribute("agent") ?? "";
|
|
383
|
+
this.name = this.getAttribute("name") ?? "Assistant";
|
|
384
|
+
this.company = this.getAttribute("company") ?? "";
|
|
385
|
+
this.tagline = this.getAttribute("tagline") ?? "";
|
|
386
|
+
this.phone = this.getAttribute("phone") ?? "";
|
|
387
|
+
this.logUrl = this.getAttribute("log-url") ?? "";
|
|
388
|
+
this.codeUrl = this.getAttribute("code-url") ?? "";
|
|
389
|
+
this.gateway = (this.getAttribute("gateway") ?? A_GATEWAY).replace(/\/+$/, "");
|
|
390
|
+
this.greeting = this.getAttribute("greeting") ?? "";
|
|
391
|
+
// Present means on, unless the value is "false".
|
|
392
|
+
this.autostart = this.hasAttribute("autostart") && this.getAttribute("autostart") !== "false";
|
|
393
|
+
const label = this.getAttribute("label") ?? `Talk to ${this.name}`;
|
|
394
|
+
this.shadowRoot.innerHTML = `
|
|
395
|
+
<style>${STYLE}</style>
|
|
396
|
+
<button class="fab" part="button">${ICONS.spark}<span>${label}</span></button>
|
|
397
|
+
<section class="panel" part="panel" hidden>
|
|
398
|
+
<header>
|
|
399
|
+
<div><h2>${this.name}</h2><p>${[this.company, this.tagline].filter(Boolean).join(" · ")}</p></div>
|
|
400
|
+
<button class="close" aria-label="Close">×</button>
|
|
401
|
+
</header>
|
|
402
|
+
<div class="body"></div>
|
|
403
|
+
<footer></footer>
|
|
404
|
+
</section>`;
|
|
405
|
+
this.$ = (selector) => this.shadowRoot.querySelector(selector);
|
|
406
|
+
this.$(".fab").addEventListener("click", () => (this.autostart ? this.start() : this.toggle()));
|
|
407
|
+
this.$(".close").addEventListener("click", () => this.hide());
|
|
408
|
+
this.menu();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
toggle() {
|
|
412
|
+
const panel = this.$(".panel");
|
|
413
|
+
if (panel.hidden) {
|
|
414
|
+
panel.hidden = false;
|
|
415
|
+
this.dispatchEvent(new CustomEvent("pinecall:open"));
|
|
416
|
+
} else {
|
|
417
|
+
this.hide();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* `autostart`: request the microphone inside the opening click (browsers require a user
|
|
423
|
+
* gesture), then release it; the room later gets its own track. On refusal, show the menu
|
|
424
|
+
* with the reason.
|
|
425
|
+
*/
|
|
426
|
+
async start() {
|
|
427
|
+
const panel = this.$(".panel");
|
|
428
|
+
if (!panel.hidden) return this.hide();
|
|
429
|
+
panel.hidden = false;
|
|
430
|
+
this.dispatchEvent(new CustomEvent("pinecall:open"));
|
|
431
|
+
try {
|
|
432
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
433
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
434
|
+
} catch (refused) {
|
|
435
|
+
this.menu();
|
|
436
|
+
this.notice(`The microphone was not allowed (${refused.name ?? "refused"}): chat instead, or allow it and try again.`);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
await this.conversation("talk");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async hide() {
|
|
443
|
+
await this.leave();
|
|
444
|
+
this.$(".panel").hidden = true;
|
|
445
|
+
this.menu();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
showTools() {
|
|
449
|
+
try {
|
|
450
|
+
return localStorage.getItem(TOOLS_SETTING) === "1";
|
|
451
|
+
} catch {
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ── views ─────────────────────────────────────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
menu() {
|
|
459
|
+
this.$(".panel").classList.remove("wide");
|
|
460
|
+
const body = this.$(".body");
|
|
461
|
+
body.innerHTML = `
|
|
462
|
+
${this.greeting ? `<p class="greeting"></p>` : ""}
|
|
463
|
+
${this.byCode() ? `<button class="option call"><span class="icon">${ICONS.phone}</span>
|
|
464
|
+
<span><b>Call us</b><span>From your phone, and follow the call here</span></span></button>`
|
|
465
|
+
: this.phone ? `<button class="option call"><span class="icon">${ICONS.phone}</span>
|
|
466
|
+
<span><b>Call us</b><span>${this.phone}</span></span></button>` : ""}
|
|
467
|
+
<button class="option talk"><span class="icon">${ICONS.mic}</span>
|
|
468
|
+
<span><b>Talk in your browser</b><span>Speak with ${this.name} now, no phone needed</span></span></button>
|
|
469
|
+
<button class="option chat"><span class="icon">${ICONS.chat}</span>
|
|
470
|
+
<span><b>Chat</b><span>Type, and ${this.name} answers</span></span></button>`;
|
|
471
|
+
this.$("footer").innerHTML = `<span class="status">${[this.company, `${this.name} answers right away`].filter(Boolean).join(" · ")}</span>`;
|
|
472
|
+
// Set as text, never HTML.
|
|
473
|
+
const greeting = body.querySelector(".greeting");
|
|
474
|
+
if (greeting) greeting.textContent = this.greeting;
|
|
475
|
+
body.querySelector(".call")?.addEventListener("click", () => this.callUs());
|
|
476
|
+
body.querySelector(".talk").addEventListener("click", () => this.conversation("talk"));
|
|
477
|
+
body.querySelector(".chat").addEventListener("click", () => this.conversation("chat"));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Footer for non-menu views: back, the tools toggle (with a log), and new/end for calls and chats. */
|
|
481
|
+
footer(end, scope = null) {
|
|
482
|
+
const footer = this.$("footer");
|
|
483
|
+
footer.innerHTML = `
|
|
484
|
+
<button class="back">‹ Back</button>
|
|
485
|
+
<span class="right">
|
|
486
|
+
${this.logUrl ? `<label class="toggle"><input type="checkbox" ${this.showTools() ? "checked" : ""}> Show tools</label>` : ""}
|
|
487
|
+
${scope ? `<button class="btn quiet again">${scope === "talk" ? "New call" : "New chat"}</button>` : ""}
|
|
488
|
+
${end ? `<button class="btn danger end">${end}</button>` : ""}
|
|
489
|
+
</span>`;
|
|
490
|
+
footer.querySelector(".back").addEventListener("click", async () => {
|
|
491
|
+
await this.leave();
|
|
492
|
+
this.menu();
|
|
493
|
+
});
|
|
494
|
+
footer.querySelector(".toggle input")?.addEventListener("change", (event) => {
|
|
495
|
+
try {
|
|
496
|
+
localStorage.setItem(TOOLS_SETTING, event.target.checked ? "1" : "0");
|
|
497
|
+
} catch {
|
|
498
|
+
// storage unavailable; the toggle still works for this page
|
|
499
|
+
}
|
|
500
|
+
this.transcript?.tools(event.target.checked);
|
|
501
|
+
});
|
|
502
|
+
footer.querySelector(".end")?.addEventListener("click", () => this.leave(true));
|
|
503
|
+
footer.querySelector(".again")?.addEventListener("click", () => this.again(scope));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Hang up the current session and start a new one on a fresh token. */
|
|
507
|
+
async again(scope) {
|
|
508
|
+
await this.leave();
|
|
509
|
+
await this.conversation(scope);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Whether "Call us" uses a code. */
|
|
513
|
+
byCode() {
|
|
514
|
+
return Boolean(this.codeUrl || this.codeProvider);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Fetch {code, number, expires_at, code_token} from `codeProvider` or `code-url`. */
|
|
518
|
+
async askCode() {
|
|
519
|
+
if (this.codeProvider) return this.codeProvider(this.agent);
|
|
520
|
+
const answer = await fetch(this.codeUrl, {
|
|
521
|
+
method: "POST",
|
|
522
|
+
headers: { "content-type": "application/json" },
|
|
523
|
+
body: JSON.stringify({ agent: this.agent }),
|
|
524
|
+
});
|
|
525
|
+
const said = await answer.json().catch(() => ({}));
|
|
526
|
+
if (!answer.ok) throw new Error(said.detail ?? `code: HTTP ${answer.status}`);
|
|
527
|
+
return said;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* "Call us" with a code: long-poll the gateway with the code token (up to 25 s per request)
|
|
532
|
+
* until a call claims it, then follow that call's log with the returned log token.
|
|
533
|
+
*/
|
|
534
|
+
async callByCode() {
|
|
535
|
+
this.$(".panel").classList.add("wide");
|
|
536
|
+
const body = this.$(".body");
|
|
537
|
+
body.innerHTML = `<span class="status">Getting your code…</span><ul class="lines"></ul>`;
|
|
538
|
+
this.footer(null);
|
|
539
|
+
let code;
|
|
540
|
+
try {
|
|
541
|
+
code = await this.askCode();
|
|
542
|
+
if (!(typeof code?.code === "string" && typeof code.number === "string" && typeof code.code_token === "string"))
|
|
543
|
+
throw new Error("the code endpoint answered with something this widget does not know");
|
|
544
|
+
} catch (refused) {
|
|
545
|
+
this.status(refused.message, "failed");
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const tel = code.number.replace(/[^\d+]/g, "");
|
|
549
|
+
body.innerHTML = `
|
|
550
|
+
<div class="dial">
|
|
551
|
+
<div class="step">
|
|
552
|
+
<span class="n">1</span><span class="say">Call ${this.name}</span>
|
|
553
|
+
<div class="number"><b></b><a>${ICONS.phone}Call</a></div>
|
|
554
|
+
</div>
|
|
555
|
+
<div class="step">
|
|
556
|
+
<span class="n">2</span><span class="say">Key this code on your phone <small>· any time during the call</small></span>
|
|
557
|
+
<div class="digits" role="img"></div>
|
|
558
|
+
</div>
|
|
559
|
+
<div class="waiting"><span class="status"></span><span class="expires"></span></div>
|
|
560
|
+
</div>
|
|
561
|
+
<div class="soon">Your conversation shows up here, live, the moment the code is keyed.</div>
|
|
562
|
+
<ul class="lines" hidden></ul>`;
|
|
563
|
+
// From the application's server: set as text, never HTML.
|
|
564
|
+
body.querySelector(".number b").textContent = spaced(code.number);
|
|
565
|
+
body.querySelector(".number a").href = `tel:${tel}`;
|
|
566
|
+
const digits = body.querySelector(".digits");
|
|
567
|
+
for (const digit of String(code.code)) digits.append(Object.assign(document.createElement("span"), { textContent: digit }));
|
|
568
|
+
digits.setAttribute("aria-label", `the code: ${String(code.code).split("").join(" ")}`);
|
|
569
|
+
this.status("Waiting for your call");
|
|
570
|
+
const expires = body.querySelector(".expires");
|
|
571
|
+
const waiting = () => {
|
|
572
|
+
const left = Math.max(0, Math.floor(code.expires_at - Date.now() / 1000));
|
|
573
|
+
expires.textContent = `expires in ${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")}`;
|
|
574
|
+
};
|
|
575
|
+
waiting();
|
|
576
|
+
this.codeClock = setInterval(waiting, 1000);
|
|
577
|
+
const stop = new AbortController();
|
|
578
|
+
this.codeWait = stop;
|
|
579
|
+
const asked = `${this.gateway}/v1/codes/${encodeURIComponent(code.code)}?wait=1&token=${encodeURIComponent(code.code_token)}`;
|
|
580
|
+
while (!stop.signal.aborted) {
|
|
581
|
+
let standing;
|
|
582
|
+
try {
|
|
583
|
+
const answer = await fetch(asked, { signal: stop.signal });
|
|
584
|
+
// Retry on 5xx and network errors; any other status is final.
|
|
585
|
+
if (answer.status >= 500) throw new Error(`code: HTTP ${answer.status}`);
|
|
586
|
+
standing = await answer.json();
|
|
587
|
+
if (!answer.ok) throw Object.assign(new Error(standing.detail ?? `code: HTTP ${answer.status}`), { final: true });
|
|
588
|
+
} catch (failed) {
|
|
589
|
+
if (stop.signal.aborted) return;
|
|
590
|
+
if (failed.final) return this.codeFailed(failed.message);
|
|
591
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
if (standing.status === "claimed") return this.followClaimed(standing);
|
|
595
|
+
if (standing.status === "expired") return this.codeFailed("The code expired.", true);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Follow the claimed call's log from the gateway. */
|
|
600
|
+
followClaimed(standing) {
|
|
601
|
+
clearInterval(this.codeClock);
|
|
602
|
+
this.codeClock = null;
|
|
603
|
+
this.$(".body .dial")?.remove();
|
|
604
|
+
this.$(".body .soon")?.remove();
|
|
605
|
+
const lines = this.$(".body .lines");
|
|
606
|
+
lines.hidden = false;
|
|
607
|
+
lines.insertAdjacentHTML("beforebegin", `<span class="status"></span>`);
|
|
608
|
+
this.status(`Live: ${this.name} is on the line.`, "live");
|
|
609
|
+
this.transcript = new Transcript(lines, () => this.showTools());
|
|
610
|
+
const call = encodeURIComponent(standing.call);
|
|
611
|
+
this.transcript.follow(
|
|
612
|
+
`${this.gateway}/v1/calls/${call}/events?types=${LOG_TYPES}&after=0&token=${encodeURIComponent(standing.log_token)}`,
|
|
613
|
+
);
|
|
614
|
+
this.transcript.mark("☎ your call", true);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Show why the code failed; offer a new one if it expired. */
|
|
618
|
+
codeFailed(said, again = false) {
|
|
619
|
+
clearInterval(this.codeClock);
|
|
620
|
+
this.codeClock = null;
|
|
621
|
+
this.status(said, "failed");
|
|
622
|
+
if (again) this.notice("Get a new code", () => this.callByCode());
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** "Call us": the number and the live call from log-url, or the code flow when configured. */
|
|
626
|
+
callUs() {
|
|
627
|
+
if (this.byCode()) return this.callByCode();
|
|
628
|
+
this.$(".panel").classList.add("wide");
|
|
629
|
+
const tel = this.phone.replace(/[^\d+]/g, "");
|
|
630
|
+
this.$(".body").innerHTML = `
|
|
631
|
+
<div class="number"><b>${this.phone}</b><a href="tel:${tel}">Call</a></div>
|
|
632
|
+
<span class="status">${this.logUrl ? "Waiting for your call…" : `Call the number and ${this.name} picks up.`}</span>
|
|
633
|
+
<ul class="lines"></ul>`;
|
|
634
|
+
this.footer(null);
|
|
635
|
+
if (!this.logUrl) return;
|
|
636
|
+
this.transcript = new Transcript(this.$(".lines"), () => this.showTools());
|
|
637
|
+
const openedAt = Date.now() / 1000 - 5;
|
|
638
|
+
let showing = null;
|
|
639
|
+
const poll = async () => {
|
|
640
|
+
try {
|
|
641
|
+
const answer = await fetch(this.log("list=1"));
|
|
642
|
+
if (!answer.ok) throw new Error(`log: HTTP ${answer.status}`);
|
|
643
|
+
const { calls } = await answer.json();
|
|
644
|
+
const live = calls.find((call) => call.live);
|
|
645
|
+
this.status(live ? `Live: ${this.name} is on the line.` : "Waiting for your call…", live ? "live" : "");
|
|
646
|
+
const current = live ?? calls.find((call) => (call.started_at ?? 0) >= openedAt);
|
|
647
|
+
if (current && showing !== current.call) {
|
|
648
|
+
showing = current.call;
|
|
649
|
+
this.transcript.follow(this.log(`call=${encodeURIComponent(current.call)}`));
|
|
650
|
+
this.transcript.mark(`☎ a call from ${current.from}`, true);
|
|
651
|
+
}
|
|
652
|
+
} catch (failed) {
|
|
653
|
+
this.status(failed.message, "failed");
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
// Poll every second while the phone rings.
|
|
657
|
+
poll();
|
|
658
|
+
this.phonePoll = setInterval(poll, 1000);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async conversation(scope) {
|
|
662
|
+
this.$(".panel").classList.add("wide");
|
|
663
|
+
const body = this.$(".body");
|
|
664
|
+
body.innerHTML = `
|
|
665
|
+
${this.greeting ? `<p class="greeting"></p>` : ""}
|
|
666
|
+
<span class="status">Connecting…</span>
|
|
667
|
+
<ul class="lines"></ul>
|
|
668
|
+
${scope === "chat" ? `<div class="row"><input type="text" placeholder="Type a message and press Enter" autocomplete="off"><button class="btn send">Send</button></div>` : ""}`;
|
|
669
|
+
body.querySelector(".greeting")?.replaceChildren(document.createTextNode(this.greeting));
|
|
670
|
+
this.footer(scope === "talk" ? "Hang up" : "End chat", scope);
|
|
671
|
+
if (scope === "chat") {
|
|
672
|
+
const input = body.querySelector("input");
|
|
673
|
+
const send = async () => {
|
|
674
|
+
const text = input.value.trim();
|
|
675
|
+
if (!text) return;
|
|
676
|
+
input.value = "";
|
|
677
|
+
const pending = this.logUrl ? null : `typed_${Date.now()}`;
|
|
678
|
+
if (pending) this.draw(pending, "user interim", text, false);
|
|
679
|
+
try {
|
|
680
|
+
if (!this.room) await this.open("chat");
|
|
681
|
+
this.transcript?.typed(text);
|
|
682
|
+
await this.send(text);
|
|
683
|
+
if (pending) this.draw(pending, "user", text, true);
|
|
684
|
+
} catch (failed) {
|
|
685
|
+
this.transcript?.untyped();
|
|
686
|
+
if (pending) this.streamLines.get(pending)?.remove();
|
|
687
|
+
input.value = text;
|
|
688
|
+
this.status(failed.message, "failed");
|
|
689
|
+
}
|
|
690
|
+
input.focus();
|
|
691
|
+
};
|
|
692
|
+
body.querySelector(".send").addEventListener("click", send);
|
|
693
|
+
input.addEventListener("keydown", (event) => {
|
|
694
|
+
if (event.key === "Enter") send();
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
try {
|
|
698
|
+
await this.open(scope);
|
|
699
|
+
} catch (failed) {
|
|
700
|
+
this.status(failed.message, "failed");
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ── room ──────────────────────────────────────────────────────────────────────
|
|
705
|
+
|
|
706
|
+
async token(scope) {
|
|
707
|
+
// `tokenProvider(scope, agent)` replaces token-url for pages that mint tokens themselves.
|
|
708
|
+
if (typeof this.tokenProvider === "function") return this.tokenProvider(scope, this.agent);
|
|
709
|
+
const url = this.getAttribute("token-url");
|
|
710
|
+
if (!url) throw new Error("pinecall-widget: token-url is missing");
|
|
711
|
+
if (!this.agent) throw new Error("pinecall-widget: agent is missing");
|
|
712
|
+
const answer = await fetch(url, {
|
|
713
|
+
method: "POST",
|
|
714
|
+
headers: { "content-type": "application/json" },
|
|
715
|
+
body: JSON.stringify({ agent: this.agent, scope }),
|
|
716
|
+
});
|
|
717
|
+
const minted = await answer.json().catch(() => ({}));
|
|
718
|
+
if (!answer.ok) throw new Error(minted.detail ?? `token endpoint: HTTP ${answer.status}`);
|
|
719
|
+
return minted;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async open(scope) {
|
|
723
|
+
await this.leave();
|
|
724
|
+
this.streamLines.clear();
|
|
725
|
+
const minted = await this.token(scope);
|
|
726
|
+
const room = new Room();
|
|
727
|
+
this.room = room;
|
|
728
|
+
this.greeted = new Promise((resolve) => {
|
|
729
|
+
this.onGreeted = resolve;
|
|
730
|
+
});
|
|
731
|
+
if (this.logUrl) {
|
|
732
|
+
this.transcript = new Transcript(this.$(".lines"), () => this.showTools());
|
|
733
|
+
this.transcript.onAgent = () => this.onGreeted?.();
|
|
734
|
+
this.transcript.onEnd = () => this.status("Ended.");
|
|
735
|
+
} else {
|
|
736
|
+
room.registerTextStreamHandler(TRANSCRIPTION, (reader, from) => this.read(reader, from.identity));
|
|
737
|
+
}
|
|
738
|
+
room.on(RoomEvent.TrackSubscribed, (track) => {
|
|
739
|
+
if (scope !== "talk" || track.kind !== Track.Kind.Audio) return;
|
|
740
|
+
const element = track.attach();
|
|
741
|
+
element.style.display = "none";
|
|
742
|
+
this.shadowRoot.append(element);
|
|
743
|
+
});
|
|
744
|
+
room.on(RoomEvent.TrackUnsubscribed, (track) => track.detach().forEach((element) => element.remove()));
|
|
745
|
+
room.on(RoomEvent.Disconnected, () => {
|
|
746
|
+
// A replaced room disconnects after the new session is shown; only emit its end event.
|
|
747
|
+
if (this.room === room) {
|
|
748
|
+
this.room = null;
|
|
749
|
+
this.status("Ended.");
|
|
750
|
+
}
|
|
751
|
+
this.dispatchEvent(new CustomEvent("pinecall:ended", { detail: { call: minted.call } }));
|
|
752
|
+
});
|
|
753
|
+
await room.connect(minted.server_url, minted.participant_token);
|
|
754
|
+
// The call exists only after the join, so follow its log now.
|
|
755
|
+
if (this.logUrl) this.transcript.follow(this.log(`call=${encodeURIComponent(minted.call)}`));
|
|
756
|
+
this.$(".body .greeting")?.remove();
|
|
757
|
+
if (scope === "talk") {
|
|
758
|
+
await room.localParticipant.setMicrophoneEnabled(true);
|
|
759
|
+
// Autoplay may be blocked: with `autostart` the user gesture is several awaits back.
|
|
760
|
+
if (!room.canPlaybackAudio) this.notice("Tap to hear the agent", () => room.startAudio());
|
|
761
|
+
}
|
|
762
|
+
this.status(scope === "talk" ? `Live. ${this.name} is listening.` : "Live.", "live");
|
|
763
|
+
this.dispatchEvent(new CustomEvent("pinecall:started", { detail: { call: minted.call, scope } }));
|
|
764
|
+
return minted.call;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async send(text) {
|
|
768
|
+
if (!this.room) throw new Error("not connected");
|
|
769
|
+
// Text sent before the agent joins is lost; wait for the greeting (max 8 s).
|
|
770
|
+
await Promise.race([this.greeted, new Promise((resolve) => setTimeout(resolve, 8000))]);
|
|
771
|
+
await this.room.localParticipant.sendText(text, { topic: CHAT });
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Leave the room, log stream and phone poll. On hang-up (`ended`) keep reading the log so the
|
|
776
|
+
* final turns still show, and do not wait for the disconnect.
|
|
777
|
+
*/
|
|
778
|
+
async leave(ended = false) {
|
|
779
|
+
const room = this.room;
|
|
780
|
+
this.room = null;
|
|
781
|
+
clearInterval(this.phonePoll);
|
|
782
|
+
this.phonePoll = null;
|
|
783
|
+
this.codeWait?.abort();
|
|
784
|
+
this.codeWait = null;
|
|
785
|
+
clearInterval(this.codeClock);
|
|
786
|
+
this.codeClock = null;
|
|
787
|
+
if (ended) {
|
|
788
|
+
this.status("Hanging up…");
|
|
789
|
+
const end = this.$("footer .end");
|
|
790
|
+
if (end) end.disabled = true;
|
|
791
|
+
const input = this.$(".body input");
|
|
792
|
+
if (input) input.disabled = true;
|
|
793
|
+
} else {
|
|
794
|
+
this.transcript?.stop();
|
|
795
|
+
this.transcript = null;
|
|
796
|
+
}
|
|
797
|
+
await Promise.race([room?.disconnect(), new Promise((resolve) => setTimeout(resolve, 3000))]);
|
|
798
|
+
if (ended) this.status("Ended.");
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// ── transcripts from LiveKit text streams (no log-url) ────────────────────────
|
|
802
|
+
|
|
803
|
+
async read(reader, identity) {
|
|
804
|
+
const id = reader.info.attributes?.[SEGMENT_ID] ?? reader.info.id;
|
|
805
|
+
const who = this.room?.remoteParticipants.get(identity)?.kind === ParticipantKind.AGENT ? "agent" : "user";
|
|
806
|
+
if (who === "agent") this.onGreeted?.();
|
|
807
|
+
let text = "";
|
|
808
|
+
for await (const chunk of reader) {
|
|
809
|
+
text += chunk;
|
|
810
|
+
this.draw(id, who, text, false);
|
|
811
|
+
}
|
|
812
|
+
this.draw(id, who, text, reader.info.attributes?.[TRANSCRIPTION_FINAL] === "true");
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
draw(id, who, text, final) {
|
|
816
|
+
const list = this.$(".lines");
|
|
817
|
+
if (!list) return;
|
|
818
|
+
let line = this.streamLines.get(id);
|
|
819
|
+
if (!line) {
|
|
820
|
+
line = document.createElement("li");
|
|
821
|
+
this.streamLines.set(id, line);
|
|
822
|
+
list.append(line);
|
|
823
|
+
}
|
|
824
|
+
line.className = `${who}${final ? "" : " interim"}`;
|
|
825
|
+
line.textContent = text;
|
|
826
|
+
list.scrollTop = list.scrollHeight;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** Show a notice under the status, as a button when `tap` is given. */
|
|
830
|
+
notice(text, tap) {
|
|
831
|
+
const body = this.$(".body");
|
|
832
|
+
if (!body) return;
|
|
833
|
+
const line = document.createElement(tap ? "button" : "span");
|
|
834
|
+
line.className = tap ? "btn quiet" : "status failed";
|
|
835
|
+
line.textContent = text;
|
|
836
|
+
if (tap) {
|
|
837
|
+
line.addEventListener("click", async () => {
|
|
838
|
+
await tap();
|
|
839
|
+
line.remove();
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
body.prepend(line);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
status(text, tone = "") {
|
|
846
|
+
const status = this.$(".body .status");
|
|
847
|
+
if (!status) return;
|
|
848
|
+
status.textContent = text;
|
|
849
|
+
status.className = `status ${tone}`;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (!customElements.get("pinecall-widget")) customElements.define("pinecall-widget", PinecallWidget);
|