thursday-agent 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/BUILD_ID +1 -1
- package/.next/build-manifest.json +3 -3
- package/.next/prerender-manifest.json +3 -3
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +2 -2
- package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/api/artifact/route.js +3 -3
- package/.next/server/app/api/artifact/route.js.nft.json +1 -1
- package/.next/server/app/api/bot/memory/files/route.js.nft.json +1 -1
- package/.next/server/app/api/bot/memory/route.js.nft.json +1 -1
- package/.next/server/app/api/bot/route.js.nft.json +1 -1
- package/.next/server/app/api/bot/thread/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/bot/thread/route.js.nft.json +1 -1
- package/.next/server/app/api/file/[...path]/route.js.nft.json +1 -1
- package/.next/server/app/api/memory/edit/route.js +1 -1
- package/.next/server/app/api/memory/edit/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/[source]/[name]/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/route.js.nft.json +1 -1
- package/.next/server/app/api/thursday/call/route.js.nft.json +1 -1
- package/.next/server/app/api/thursday/skills/route.js.nft.json +1 -1
- package/.next/server/app/api/thursday/tool-call/route.js +1 -1
- package/.next/server/app/api/thursday/tool-call/route.js.nft.json +1 -1
- package/.next/server/app/api/workspace/missing/route.js +2 -2
- package/.next/server/app/api/workspace/missing/route.js.nft.json +1 -1
- package/.next/server/app/api/workspace/route.js.nft.json +1 -1
- package/.next/server/app/artifact/[...path]/page/server-reference-manifest.json +1 -1
- package/.next/server/app/artifact/[...path]/page.js.nft.json +1 -1
- package/.next/server/app/artifact/[...path]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/page/server-reference-manifest.json +42 -42
- package/.next/server/app/page.js +5 -4
- package/.next/server/app/page.js.nft.json +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/chunks/[root-of-the-server]__0-06t3f._.js +8 -0
- package/.next/server/chunks/{[root-of-the-server]__049f521._.js → [root-of-the-server]__06l05n_._.js} +1 -1
- package/.next/server/chunks/[root-of-the-server]__0_ue8oe._.js +1 -1
- package/.next/server/chunks/{[root-of-the-server]__0ye3da2._.js → [root-of-the-server]__0btqrct._.js} +1 -1
- package/.next/server/chunks/[root-of-the-server]__0c9fgca._.js +1 -1
- package/.next/server/chunks/[root-of-the-server]__0zp35ul._.js +1 -1
- package/.next/server/chunks/[root-of-the-server]__1kv8p3e._.js +1 -1
- package/.next/server/chunks/_02szwk8._.js +1 -1
- package/.next/server/chunks/_06c9kie._.js +24 -24
- package/.next/server/chunks/_0i55en5._.js +6 -6
- package/.next/server/chunks/_0p56fn0._.js +1 -1
- package/.next/server/chunks/_11ujpya._.js +1 -1
- package/.next/server/chunks/_1dbixxz._.js +1 -1
- package/.next/server/chunks/_1dc_d1q._.js +16 -16
- package/.next/server/chunks/_1gnjfli._.js +3 -0
- package/.next/server/chunks/_1klbcft._.js +1 -1
- package/.next/server/chunks/features_0-cnt0l._.js +1 -1
- package/.next/server/chunks/features_0hdaci0._.js +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0-e011f._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0pd8yr7._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__1nygfik._.js +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__1sdphb0._.js +1 -1
- package/.next/server/chunks/ssr/{[root-of-the-server]__1fll8dc._.js → [root-of-the-server]__1vdmfbe._.js} +2 -2
- package/.next/server/chunks/ssr/[root-of-the-server]__1waa3rc._.js +34 -49
- package/.next/server/chunks/ssr/{_1anqots._.js → _0-2qsxo._.js} +2 -2
- package/.next/server/chunks/ssr/_02oc_h5._.js +9 -9
- package/.next/server/chunks/ssr/_05yghx6._.js +1 -1
- package/.next/server/chunks/ssr/_07nbwap._.js +3 -0
- package/.next/server/chunks/ssr/_08c4z-9._.js +1 -1
- package/.next/server/chunks/ssr/_0_qfdlw._.js +1 -1
- package/.next/server/chunks/ssr/_0b6_jv-._.js +3 -0
- package/.next/server/chunks/ssr/_0hxt2ul._.js +1 -1
- package/.next/server/chunks/ssr/_0i_6q1w._.js +1 -1
- package/.next/server/chunks/ssr/{_18xqmny._.js → _0no7i1o._.js} +2 -2
- package/.next/server/chunks/ssr/_0tzg--b._.js +3 -0
- package/.next/server/chunks/ssr/{_1ztk7p6._.js → _114ylv7._.js} +3 -3
- package/.next/server/chunks/ssr/{_1k0spz7._.js → _12c-iwh._.js} +2 -2
- package/.next/server/chunks/ssr/_12w42gi._.js +1 -1
- package/.next/server/chunks/ssr/{_0eaog6_._.js → _167hchk._.js} +2 -2
- package/.next/server/chunks/ssr/{_1x9pskj._.js → _1_x5spz._.js} +3 -3
- package/.next/server/chunks/ssr/{_0dr-ddg._.js → _1a61fc9._.js} +2 -2
- package/.next/server/chunks/ssr/_1jmz33w._.js +1 -1
- package/.next/server/chunks/ssr/{_1y6b5mr._.js → _1w_t7h7._.js} +2 -2
- package/.next/server/chunks/ssr/{_0dh2e1j._.js → _1wyfaq_._.js} +7 -7
- package/.next/server/chunks/ssr/{[root-of-the-server]__0bxsfyn._.js → _200gt-m._.js} +4 -3
- package/.next/server/chunks/ssr/features_config_components_config-setting_tsx_0xfwqdg._.js +1 -1
- package/.next/server/chunks/ssr/features_connectors_components_mcp-setting_tsx_0jmbgvp._.js +1 -1
- package/.next/server/chunks/ssr/{_0c9e5w6._.js → features_workspace_00jz2hu._.js} +3 -4
- package/.next/server/chunks/ssr/{node_modules_0746rzi._.js → node_modules_02ai7br._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_0vsxbzp._.js → node_modules_02deexj._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_1g1eihe._.js → node_modules_07za262._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_0332gw4._.js → node_modules_0_s9get._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_1gpednh._.js → node_modules_0n3c7h0._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_0t-9nup._.js → node_modules_0od6jwr._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_0mxvg8h._.js → node_modules_0rdt7pk._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_0vv0-fg._.js → node_modules_0w1z7kz._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_1d53yiq._.js → node_modules_0wdupfz._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_1u6w1pb._.js → node_modules_1720w0a._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_1gu1pph._.js → node_modules_18lsg7d._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_1he1w8_._.js → node_modules_1b94fxf._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_0qbfyjh._.js → node_modules_1ecdo1s._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_0qapgjr._.js → node_modules_1fa9ag7._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_0d5uzof._.js → node_modules_1fv-_si._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_1lnjapm._.js → node_modules_1hf461g._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_1-urho9._.js → node_modules_1m71ta-._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_1lynj9m._.js → node_modules_1n93n-g._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_0fr-ml8._.js → node_modules_1n9lqu9._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_04vxchl._.js → node_modules_1npnsvd._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_0exkwcm._.js → node_modules_1rn2r0n._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_022sipm._.js → node_modules_1vq-mfk._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_00_51_v._.js → node_modules_1vzyw1t._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_0wsvp00._.js → node_modules_1w359w4._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_1xn6saw._.js → node_modules_1yajbl6._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_16pi_dd._.js → node_modules_20eqhy8._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_067_exa._.js → node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_019sbis._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_0nrrdaq._.js → node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_0s6piq3._.js} +1 -1
- package/.next/server/chunks/ssr/{node_modules_@vercel_oidc_dist_21a5haa._.js → node_modules_@vercel_oidc_dist_05itbb1._.js} +1 -1
- package/.next/server/middleware-build-manifest.js +3 -3
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.js +1 -1
- package/.next/server/server-reference-manifest.json +43 -43
- package/.next/static/chunks/{0dr16v67vj4n7.js → 07vqbk_9t4em4.js} +2 -2
- package/.next/static/chunks/{2nvepds1fu2ux.js → 08krja1zx7nqg.js} +1 -1
- package/.next/static/chunks/{42wcg3wfg_m_p.js → 0qcuwdk132jjt.js} +1 -1
- package/.next/static/chunks/{2_pnccjp6j1ca.js → 0z69gvysmave7.js} +1 -1
- package/.next/static/chunks/12fvz1w5ows75.js +1 -0
- package/.next/static/chunks/14t03ned286iu.js +1 -0
- package/.next/static/chunks/{2jlz8zp4jax_m.js → 156xxbkrz5lzu.js} +1 -1
- package/.next/static/chunks/1fz14reod4lft.js +1 -0
- package/.next/static/chunks/{0joan-69kbspd.js → 1g23dbabl-vkr.js} +1 -1
- package/.next/static/chunks/{18mkhstgiqcbt.js → 1qy_rc3oxfxok.js} +1 -1
- package/.next/static/chunks/1zea7qdl-7365.js +1 -0
- package/.next/static/chunks/1zvqumkg7kmo0.css +1 -0
- package/.next/static/chunks/26hpqt3y6bogl.js +12 -0
- package/.next/static/chunks/{3zk6q3-3f9swq.js → 2_j5mqm6d-z18.js} +1 -1
- package/.next/static/chunks/{1vj_5bnbxx84e.js → 2dt6tu7a22svs.js} +1 -1
- package/.next/static/chunks/2h5ls3d86kya6.js +1 -0
- package/.next/static/chunks/2wc6y1ht6dfuy.js +1 -0
- package/.next/static/chunks/{1i-u2wzub_to1.js → 2xr0zpll7thyi.js} +6 -6
- package/.next/static/chunks/{26ql4y-43xwhp.js → 31jztc-yq30m7.js} +1 -1
- package/.next/static/chunks/391af0i8eljzk.js +1 -0
- package/.next/static/chunks/{1kp5nfp06nj9h.js → 3p2s2cjokx1aa.js} +2 -2
- package/.next/static/chunks/{1wlo979mx-y3w.js → 41s3fml5oivsa.js} +1 -1
- package/README.md +60 -19
- package/docs/how-it-works.md +2 -0
- package/docs/live-calls.md +38 -33
- package/features/ai/load-tools.ts +4 -2
- package/features/ai/prompts/bot.prompt.ts +5 -5
- package/features/ai/prompts/live.prompt.ts +36 -132
- package/features/ai/prompts/prompt-helper.ts +5 -4
- package/features/ai/prompts/thursday.prompt.ts +75 -54
- package/features/ai/tools/call.tool.ts +32 -4
- package/features/ai/tools/tool-name.ts +1 -0
- package/features/bot/bot.runner.ts +5 -5
- package/features/thursday/ascii.const.ts +74 -0
- package/features/thursday/components/ascii-orb.tsx +173 -30
- package/features/thursday/components/face.tsx +6 -1
- package/features/thursday/components/side-captions.tsx +273 -0
- package/features/thursday/components/tab-state.tsx +11 -3
- package/features/thursday/components/thursday-setting.tsx +6 -5
- package/features/thursday/components/thursday.tsx +227 -251
- package/features/thursday/face.store.ts +5 -0
- package/features/thursday/thursday.action.ts +4 -6
- package/features/thursday/thursday.schema.ts +10 -5
- package/features/thursday/thursday.store.ts +3 -0
- package/features/thursday/use-thursday.ts +461 -392
- package/features/workspace/file-kind.ts +9 -0
- package/hooks/use-hotkey.ts +3 -2
- package/lib/live/live.schema.ts +0 -21
- package/lib/live/live.server.ts +1 -3
- package/package.json +1 -1
- package/scripts/artifact-paths.test.mts +13 -0
- package/scripts/live-session.test.mts +56 -66
- package/.next/server/chunks/[root-of-the-server]__0g-ueei._.js +0 -8
- package/.next/server/chunks/node_modules_consola_dist_chunks_prompt_mjs_1kipdeo._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__172cq4x._.js +0 -3
- package/.next/server/chunks/ssr/_03_928f._.js +0 -3
- package/.next/server/chunks/ssr/_0g_rlog._.js +0 -3
- package/.next/server/chunks/ssr/_123ttsf._.js +0 -3
- package/.next/static/chunks/10k2xvap03z20.css +0 -1
- package/.next/static/chunks/1t9qms5pwwa3n.js +0 -1
- package/.next/static/chunks/2a51-ywz9b2a3.js +0 -1
- package/.next/static/chunks/2fvdgxih7x7hr.js +0 -1
- package/.next/static/chunks/3addj3i4-izma.js +0 -1
- package/.next/static/chunks/3gfh7nxwusbt0.js +0 -1
- package/.next/static/chunks/3gwa44cvz4qoi.js +0 -1
- package/.next/static/chunks/3zkg5iup3v4a2.js +0 -12
- package/.next/static/chunks/405m0bqtoihev.js +0 -1
- /package/.next/static/{0qzGoKn2wTERVZtblX1tp → yyRVBc0xxYQsh3GpbQX9b}/_buildManifest.js +0 -0
- /package/.next/static/{0qzGoKn2wTERVZtblX1tp → yyRVBc0xxYQsh3GpbQX9b}/_clientMiddlewareManifest.js +0 -0
- /package/.next/static/{0qzGoKn2wTERVZtblX1tp → yyRVBc0xxYQsh3GpbQX9b}/_ssgManifest.js +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { useAppEvent } from "@/app/api/events/app-event.client";
|
|
5
5
|
import { queryKey } from "@/app/api/query-key";
|
|
6
6
|
import { toast } from "@/components/ui/toast";
|
|
7
|
-
import { CALL_END, CALL_IDLE, CALL_RELAY } from "@/config";
|
|
7
|
+
import { CALL_BACK, CALL_END, CALL_IDLE, CALL_RELAY } from "@/config";
|
|
8
8
|
import { TOOL_NAMES } from "@/features/ai/tools/tool-name";
|
|
9
9
|
import { acceptThreadRelaysAction } from "@/features/bot/bot.action";
|
|
10
10
|
import { type Bot, isAppStop, type Thread } from "@/features/bot/bot.schema";
|
|
@@ -33,20 +33,27 @@ import {
|
|
|
33
33
|
} from "@/lib/protocol/use-server-route";
|
|
34
34
|
import { createOutbox, type Outbox } from "@/lib/queue";
|
|
35
35
|
import { errorToString } from "@/lib/utils";
|
|
36
|
+
import { FACE_WORD_MAX, undrawable } from "./ascii.const";
|
|
36
37
|
import {
|
|
37
38
|
endCallAction,
|
|
38
39
|
openCallAction,
|
|
39
40
|
saveThoughtAction,
|
|
40
41
|
saveTurnsAction,
|
|
41
42
|
} from "./thursday.action";
|
|
42
|
-
import type {
|
|
43
|
+
import type {
|
|
44
|
+
CallBack,
|
|
45
|
+
CallMessage,
|
|
46
|
+
CallStatus,
|
|
47
|
+
FaceWord,
|
|
48
|
+
LiveStatus,
|
|
49
|
+
} from "./thursday.schema";
|
|
43
50
|
import { thursdaySettings, useThursdayStore } from "./thursday.store";
|
|
44
51
|
import { toolBot, toolLine } from "./tool-line";
|
|
45
52
|
|
|
46
53
|
/**
|
|
47
54
|
* One live call, plus the thread inbox the app watches even with no call open.
|
|
48
55
|
* The server opens the Live session (openCallAction) and runs the tools
|
|
49
|
-
* (tool-call); the page itself only hangs up.
|
|
56
|
+
* (tool-call); the page itself only hangs up and puts a word on the face.
|
|
50
57
|
*/
|
|
51
58
|
|
|
52
59
|
/** Plays when the line opens. */
|
|
@@ -97,16 +104,6 @@ const THREAD_POLL_FALLBACK_MS = 30_000;
|
|
|
97
104
|
/** Failed saves of one kind before the call stops saving that kind and says so, once. */
|
|
98
105
|
const SAVE_FAILURE_LIMIT = 3;
|
|
99
106
|
|
|
100
|
-
/**
|
|
101
|
-
* Put in as trusted behaviour just ahead of the first relay of a call. The relay
|
|
102
|
-
* itself carries facts only, since the backend reads relays too and routes answers
|
|
103
|
-
* by them.
|
|
104
|
-
*/
|
|
105
|
-
const RELAY_NOTE =
|
|
106
|
-
"An update from background work follows. If you have already told the user what it says, it need not be said again.";
|
|
107
|
-
|
|
108
|
-
const IDLE_LINE = `The user has said nothing for ${Math.round(CALL_IDLE.hangUpMs / 1000)} seconds. Say a one-line goodbye and end the call.`;
|
|
109
|
-
|
|
110
107
|
/**
|
|
111
108
|
* What the activity line draws: a tool the model is using, or a relay. `line` is
|
|
112
109
|
* the human-readable sentence (tool-line), null when none exists; `name` is the
|
|
@@ -124,6 +121,22 @@ export type ActivityLine = {
|
|
|
124
121
|
bot?: string | null;
|
|
125
122
|
};
|
|
126
123
|
|
|
124
|
+
/** A call-back ringing, as the screen names it. */
|
|
125
|
+
export type Ringing = {
|
|
126
|
+
/** The bot whose work rang: the one asking, else the thread's own. */
|
|
127
|
+
bot: string;
|
|
128
|
+
kind: "question" | "done" | "stopped";
|
|
129
|
+
label: string;
|
|
130
|
+
/** Other threads ringing with it. */
|
|
131
|
+
more: number;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Why a call ended without the user hanging up: the line was quiet (CALL_IDLE),
|
|
136
|
+
* she hung up (`end_call`), Live closed the session, or the connection dropped.
|
|
137
|
+
*/
|
|
138
|
+
export type CallEnd = "quiet" | "hungUp" | "closed" | "dropped";
|
|
139
|
+
|
|
127
140
|
export function useThursday() {
|
|
128
141
|
const session = useRef<LiveSession | null>(null);
|
|
129
142
|
// created on the first call: `new Audio()` cannot run during SSR
|
|
@@ -148,8 +161,14 @@ export function useThursday() {
|
|
|
148
161
|
const [since, setSince] = useState<number | null>(null);
|
|
149
162
|
/** Seconds until idle hang-up; set only inside CALL_IDLE.warnMs. */
|
|
150
163
|
const [idleLeft, setIdleLeft] = useState<number | null>(null);
|
|
164
|
+
/** Why the last call ended, when the user did not end it; cleared by the next call. */
|
|
165
|
+
const [ended, setEnded] = useState<CallEnd | null>(null);
|
|
151
166
|
/** When the backend picked this turn up (ms); the activity line counts from it. */
|
|
152
167
|
const [thinkingSince, setThinkingSince] = useState<number | null>(null);
|
|
168
|
+
/** The title of the backend's latest reasoning summary in this stretch of work. */
|
|
169
|
+
const [thinkingTitle, setThinkingTitle] = useState<string | null>(null);
|
|
170
|
+
/** The word `emote` last put on the face. */
|
|
171
|
+
const [faceWord, setFaceWord] = useState<FaceWord | null>(null);
|
|
153
172
|
/** The same value where callbacks can read it, and the timer that ends it. */
|
|
154
173
|
const thinking = useRef<number | null>(null);
|
|
155
174
|
const thinkTail = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -164,16 +183,8 @@ export function useThursday() {
|
|
|
164
183
|
}>({ on: false, spoke: false, giveUp: null });
|
|
165
184
|
/** What `session.closed` confirmed for the call being ended; recorded on its row. */
|
|
166
185
|
const finalized = useRef<LiveClose | null>(null);
|
|
167
|
-
/**
|
|
168
|
-
|
|
169
|
-
* started being owed (null once she answers or works).
|
|
170
|
-
*/
|
|
171
|
-
const idle = useRef<{
|
|
172
|
-
since: number;
|
|
173
|
-
asked: boolean;
|
|
174
|
-
owed: number | null;
|
|
175
|
-
grace: ReturnType<typeof setTimeout> | null;
|
|
176
|
-
}>({ since: 0, asked: false, owed: null, grace: null });
|
|
186
|
+
/** When the line last had new words from the user, her voice or backend work: the idle clock. */
|
|
187
|
+
const stirred = useRef(0);
|
|
177
188
|
const linger = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
178
189
|
/** Whether the line holds a relay, so only a relay is finished by her voice. */
|
|
179
190
|
const relayOpen = useRef(false);
|
|
@@ -188,6 +199,14 @@ export function useThursday() {
|
|
|
188
199
|
* asks or ends again is new work.
|
|
189
200
|
*/
|
|
190
201
|
const told = useRef(new Set<string>());
|
|
202
|
+
/**
|
|
203
|
+
* The call-back rings only for threads that changed after this: the last call's
|
|
204
|
+
* end, the last ring, or the page opening.
|
|
205
|
+
*/
|
|
206
|
+
const ringAfter = useRef(Date.now());
|
|
207
|
+
/** The threads a call-back is ringing for, until it is answered, declined or rings out. */
|
|
208
|
+
const [ringingFor, setRingingFor] = useState<string[]>([]);
|
|
209
|
+
const isRinging = ringingFor.length > 0;
|
|
191
210
|
/**
|
|
192
211
|
* Keys put in this call that her voice has not carried yet: rejected, cut short
|
|
193
212
|
* by a hang-up, or never spoken. Hanging up takes them out of `told`, so the
|
|
@@ -196,13 +215,8 @@ export function useThursday() {
|
|
|
196
215
|
const unvoiced = useRef(new Set<string>());
|
|
197
216
|
/** The keys of the update on the line now, carried once her voice starts and stops on it. */
|
|
198
217
|
const onLine = useRef<string[]>([]);
|
|
199
|
-
/**
|
|
200
|
-
* When her voice was last heard: for letting a goodbye finish (CALL_END), and for
|
|
201
|
-
* telling words she already answered from words still owed an answer.
|
|
202
|
-
*/
|
|
218
|
+
/** When her voice was last heard, for letting a goodbye finish (CALL_END). */
|
|
203
219
|
const voiced = useRef(0);
|
|
204
|
-
/** The relay note went in on this call (RELAY_NOTE). */
|
|
205
|
-
const relayNoted = useRef(false);
|
|
206
220
|
/** The hang-up waiting on her goodbye once the backend called end_call. */
|
|
207
221
|
const leaving = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
208
222
|
/** When either side's words were last transcribed: the quiet clock for relays. */
|
|
@@ -307,6 +321,8 @@ export function useThursday() {
|
|
|
307
321
|
const setThinking = useCallback((at: number | null) => {
|
|
308
322
|
thinking.current = at;
|
|
309
323
|
setThinkingSince(at);
|
|
324
|
+
// a stretch of work that starts or ends has not said what it is about yet
|
|
325
|
+
setThinkingTitle(null);
|
|
310
326
|
}, []);
|
|
311
327
|
|
|
312
328
|
/**
|
|
@@ -357,7 +373,7 @@ export function useThursday() {
|
|
|
357
373
|
const live = session.current;
|
|
358
374
|
const threads = latest.current;
|
|
359
375
|
if (!live || !threads || reading.current.on || acting.current) return;
|
|
360
|
-
if (leaving.current
|
|
376
|
+
if (leaving.current) return;
|
|
361
377
|
// On a call-back, why she called goes in once the opening is voiced: it is the
|
|
362
378
|
// first thing they ask, and a quiet line would come too late
|
|
363
379
|
const rung = rang.current;
|
|
@@ -383,11 +399,6 @@ export function useThursday() {
|
|
|
383
399
|
onLine.current = due.map((item) => item.key);
|
|
384
400
|
// On the line before it goes out, so it runs there for the whole wait
|
|
385
401
|
showRelay((due.at(-1) ?? first).show);
|
|
386
|
-
// Appends go out in order, so the note is in her context before the first update
|
|
387
|
-
if (!relayNoted.current) {
|
|
388
|
-
relayNoted.current = true;
|
|
389
|
-
void live.append("instructions", RELAY_NOTE);
|
|
390
|
-
}
|
|
391
402
|
void live
|
|
392
403
|
.append(
|
|
393
404
|
"commentary",
|
|
@@ -524,60 +535,63 @@ export function useThursday() {
|
|
|
524
535
|
[outbox],
|
|
525
536
|
);
|
|
526
537
|
|
|
527
|
-
/** Closes the session and resets state. Turns were saved during the call. */
|
|
528
|
-
const hangUp = useCallback(
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
session.current = null;
|
|
538
|
-
callId.current = null;
|
|
539
|
-
calling.current = false;
|
|
540
|
-
opening.current = false;
|
|
541
|
-
relayNoted.current = false;
|
|
542
|
-
rang.current = false;
|
|
543
|
-
// What she did not voice goes in again next call; unsent context goes with the session
|
|
544
|
-
for (const key of unvoiced.current) told.current.delete(key);
|
|
545
|
-
unvoiced.current.clear();
|
|
546
|
-
outbox.close();
|
|
547
|
-
outbox.clear();
|
|
548
|
-
working.current?.abort();
|
|
549
|
-
working.current = null;
|
|
550
|
-
doneReading();
|
|
551
|
-
if (linger.current) clearTimeout(linger.current);
|
|
552
|
-
// a pending face timer must not fire after the call
|
|
553
|
-
restFace();
|
|
554
|
-
if (idle.current.grace) clearTimeout(idle.current.grace);
|
|
555
|
-
idle.current = { since: 0, asked: false, owed: null, grace: null };
|
|
556
|
-
setIdleLeft(null);
|
|
557
|
-
setSince(null);
|
|
558
|
-
setStatus(live ? "ending" : "idle");
|
|
559
|
-
setMessages([]);
|
|
560
|
-
setTool(null);
|
|
561
|
-
setThinking(null);
|
|
562
|
-
if (thinkTail.current) clearTimeout(thinkTail.current);
|
|
563
|
-
thinkTail.current = null;
|
|
564
|
-
relayOpen.current = false;
|
|
565
|
-
// the room is not cleared: threads outlive the call
|
|
538
|
+
/** Closes the session and resets state. Turns were saved during the call. `why` is null when the user hung up. */
|
|
539
|
+
const hangUp = useCallback(
|
|
540
|
+
async (why: CallEnd | null = null) => {
|
|
541
|
+
if (ending.current) return;
|
|
542
|
+
ending.current = true;
|
|
543
|
+
if (leaving.current) clearInterval(leaving.current);
|
|
544
|
+
leaving.current = null;
|
|
545
|
+
attempt.current += 1;
|
|
546
|
+
const live = session.current;
|
|
547
|
+
const call = callId.current;
|
|
566
548
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
549
|
+
session.current = null;
|
|
550
|
+
callId.current = null;
|
|
551
|
+
calling.current = false;
|
|
552
|
+
opening.current = false;
|
|
553
|
+
rang.current = false;
|
|
554
|
+
// What she did not voice goes in again next call; unsent context goes with the session
|
|
555
|
+
for (const key of unvoiced.current) told.current.delete(key);
|
|
556
|
+
unvoiced.current.clear();
|
|
557
|
+
outbox.close();
|
|
558
|
+
outbox.clear();
|
|
559
|
+
working.current?.abort();
|
|
560
|
+
working.current = null;
|
|
561
|
+
doneReading();
|
|
562
|
+
if (linger.current) clearTimeout(linger.current);
|
|
563
|
+
// a pending face timer must not fire after the call
|
|
564
|
+
restFace();
|
|
565
|
+
// What came up on this call was this call's to tell; the call-back rings only for what comes after
|
|
566
|
+
ringAfter.current = Date.now();
|
|
567
|
+
setIdleLeft(null);
|
|
568
|
+
setSince(null);
|
|
569
|
+
setStatus(live ? "ending" : "idle");
|
|
570
|
+
setMessages([]);
|
|
571
|
+
setTool(null);
|
|
572
|
+
setThinking(null);
|
|
573
|
+
if (thinkTail.current) clearTimeout(thinkTail.current);
|
|
574
|
+
thinkTail.current = null;
|
|
575
|
+
relayOpen.current = false;
|
|
576
|
+
// the room is not cleared: threads outlive the call
|
|
577
|
+
|
|
578
|
+
// Waits (bounded) for session.closed, which saves the last turns and says
|
|
579
|
+
// what was billed; the row is ended with that, after the saves it queued
|
|
580
|
+
await live?.close();
|
|
581
|
+
const close = finalized.current;
|
|
582
|
+
finalized.current = null;
|
|
583
|
+
ending.current = false;
|
|
584
|
+
setStatus("idle");
|
|
585
|
+
if (live) setEnded(why);
|
|
586
|
+
if (call) void endCallAction(call, close);
|
|
587
|
+
// only when there was a line to close
|
|
588
|
+
if (live && farewell.current) {
|
|
589
|
+
farewell.current.currentTime = 0;
|
|
590
|
+
void farewell.current.play().catch(() => {});
|
|
591
|
+
}
|
|
592
|
+
},
|
|
593
|
+
[outbox, restFace, doneReading, setThinking],
|
|
594
|
+
);
|
|
581
595
|
|
|
582
596
|
// Unmount during a call must release the mic and stop tools
|
|
583
597
|
useEffect(() => {
|
|
@@ -599,7 +613,6 @@ export function useThursday() {
|
|
|
599
613
|
if (faceIn.current) clearTimeout(faceIn.current);
|
|
600
614
|
if (reading.current.giveUp) clearTimeout(reading.current.giveUp);
|
|
601
615
|
if (thinkTail.current) clearTimeout(thinkTail.current);
|
|
602
|
-
if (idle.current.grace) clearTimeout(idle.current.grace);
|
|
603
616
|
if (failedFor.current) clearTimeout(failedFor.current);
|
|
604
617
|
if (leaving.current) clearInterval(leaving.current);
|
|
605
618
|
};
|
|
@@ -620,62 +633,25 @@ export function useThursday() {
|
|
|
620
633
|
const over = said
|
|
621
634
|
? now - Math.max(voiced.current, asked) >= CALL_END.quietMs
|
|
622
635
|
: now - asked >= CALL_END.unsaidMs;
|
|
623
|
-
if (over || now - asked >= CALL_END.maxMs) void hangUp();
|
|
636
|
+
if (over || now - asked >= CALL_END.maxMs) void hangUp("hungUp");
|
|
624
637
|
}, 100);
|
|
625
638
|
}, [hangUp]);
|
|
626
639
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
* `said`: an answer is owed from then, and a requested goodbye is off. Transcripts
|
|
630
|
-
* lag the audio, so her voice heard since `said` already answered them. `agent` is
|
|
631
|
-
* her voice or backend work, which settles what is owed but cannot cancel its own
|
|
632
|
-
* goodbye.
|
|
633
|
-
*/
|
|
634
|
-
const stir = useCallback((who: "user" | "agent", said = 0) => {
|
|
635
|
-
const now = Date.now();
|
|
636
|
-
idle.current.since = now;
|
|
637
|
-
if (who !== "user") {
|
|
638
|
-
idle.current.owed = null;
|
|
639
|
-
return;
|
|
640
|
-
}
|
|
641
|
-
if (voiced.current < said) idle.current.owed ??= now;
|
|
642
|
-
if (idle.current.grace) clearTimeout(idle.current.grace);
|
|
643
|
-
idle.current.grace = null;
|
|
644
|
-
idle.current.asked = false;
|
|
645
|
-
}, []);
|
|
646
|
-
|
|
647
|
-
// Idle clock: shows the countdown, then asks for a goodbye and grants a grace period
|
|
640
|
+
// Idle clock: shows the countdown, then hangs up. The page ends a quiet line itself;
|
|
641
|
+
// a goodbye would leave the ending to the model
|
|
648
642
|
useEffect(() => {
|
|
649
643
|
if (!onCall) return;
|
|
650
|
-
|
|
644
|
+
stirred.current = Date.now();
|
|
651
645
|
const tick = setInterval(() => {
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
const owed = idle.current.owed;
|
|
655
|
-
if (owed !== null && Date.now() - owed >= CALL_IDLE.agentSilentMs) {
|
|
656
|
-
// Nothing to say goodbye with; the model is what would have said it.
|
|
657
|
-
toast.add({
|
|
658
|
-
type: "error",
|
|
659
|
-
title: "Call ended",
|
|
660
|
-
description: "The model stopped answering.",
|
|
661
|
-
});
|
|
662
|
-
void hangUp();
|
|
663
|
-
return;
|
|
664
|
-
}
|
|
665
|
-
const left = CALL_IDLE.hangUpMs - (Date.now() - idle.current.since);
|
|
646
|
+
if (!session.current) return;
|
|
647
|
+
const left = CALL_IDLE.hangUpMs - (Date.now() - stirred.current);
|
|
666
648
|
setIdleLeft(
|
|
667
|
-
left <= CALL_IDLE.warnMs
|
|
668
|
-
? Math.max(0, Math.ceil(left / 1000))
|
|
669
|
-
: null,
|
|
649
|
+
left <= CALL_IDLE.warnMs ? Math.max(0, Math.ceil(left / 1000)) : null,
|
|
670
650
|
);
|
|
671
|
-
if (left
|
|
672
|
-
|
|
673
|
-
idle.current.asked = true;
|
|
674
|
-
void live.append("instructions", IDLE_LINE);
|
|
675
|
-
idle.current.grace = setTimeout(() => void hangUp(), CALL_IDLE.graceMs);
|
|
651
|
+
if (left <= 0) void hangUp("quiet");
|
|
676
652
|
}, 1000);
|
|
677
653
|
return () => clearInterval(tick);
|
|
678
|
-
}, [onCall,
|
|
654
|
+
}, [onCall, hangUp]);
|
|
679
655
|
|
|
680
656
|
// The relay clock: open work goes in only while the line is quiet
|
|
681
657
|
useEffect(() => {
|
|
@@ -684,278 +660,317 @@ export function useThursday() {
|
|
|
684
660
|
return () => clearInterval(tick);
|
|
685
661
|
}, [onCall, relayOpenWork]);
|
|
686
662
|
|
|
687
|
-
const call = useCallback(
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
663
|
+
const call = useCallback(async () => {
|
|
664
|
+
// Every way in answers a ringing call-back: the face, the wake word, the hotkey
|
|
665
|
+
const calledBack = isRinging;
|
|
666
|
+
// Guard with a ref, not `status`: three entry points (face, wake word,
|
|
667
|
+
// hotkey) can fire in one frame and both see a stale "idle", opening two sessions
|
|
668
|
+
if (opening.current || ending.current) return;
|
|
669
|
+
// with a line open this press hangs up; `calling` covers the gap before re-render
|
|
670
|
+
if (calling.current || status !== "idle") return hangUp();
|
|
671
|
+
setRingingFor([]);
|
|
672
|
+
|
|
673
|
+
setStatus("connecting");
|
|
674
|
+
setEnded(null);
|
|
675
|
+
showFailed(false);
|
|
676
|
+
// from here on this is a call; the outbox holds updates until the session exists
|
|
677
|
+
opening.current = true;
|
|
678
|
+
calling.current = true;
|
|
679
|
+
finalized.current = null;
|
|
680
|
+
outbox.clear();
|
|
681
|
+
attempt.current += 1;
|
|
682
|
+
const mine = attempt.current;
|
|
683
|
+
const current = () => calling.current && attempt.current === mine;
|
|
684
|
+
try {
|
|
685
|
+
// Inside the gesture, before anything awaits: an AudioContext created later
|
|
686
|
+
// starts suspended. Calls from the wake word or a call-back have no gesture;
|
|
687
|
+
// armAudioUnlock handles those
|
|
688
|
+
tap.current ??= createAudioTap();
|
|
689
|
+
armAudioUnlock(tap.current.open().context);
|
|
690
|
+
const chime = new Audio(CONNECTED_SOUND);
|
|
691
|
+
farewell.current ??= new Audio(HUNG_UP_SOUND);
|
|
692
|
+
|
|
693
|
+
const settings = thursdaySettings();
|
|
694
|
+
const stop = new AbortController();
|
|
695
|
+
working.current = stop;
|
|
696
|
+
const saving = { failures: 0 };
|
|
697
|
+
const thinkingSaves = { failures: 0 };
|
|
698
|
+
/** Filled in by the handshake, read by callbacks that only run after it. */
|
|
699
|
+
const line = { callId: "", opening: null as string | null };
|
|
700
|
+
|
|
701
|
+
// one turn per id; the session reports display groups one at a time
|
|
702
|
+
const turns = new Map<string, CallMessage & { seq: number }>();
|
|
703
|
+
|
|
704
|
+
const live = await openLiveSession({
|
|
705
|
+
initialize: async (sdp) => {
|
|
706
|
+
const handshake = unwrapResult(
|
|
707
|
+
await openCallAction(settings, sdp, calledBack),
|
|
708
|
+
);
|
|
709
|
+
if (!current()) {
|
|
710
|
+
void endCallAction(handshake.callId);
|
|
711
|
+
throw new Error("The call closed during startup.");
|
|
712
|
+
}
|
|
713
|
+
callId.current = handshake.callId;
|
|
714
|
+
line.callId = handshake.callId;
|
|
715
|
+
line.opening = handshake.opening;
|
|
716
|
+
return handshake.sdp;
|
|
717
|
+
},
|
|
718
|
+
audio: tap.current,
|
|
719
|
+
on: {
|
|
720
|
+
// the backend calls tools; the page forwards them to the server
|
|
721
|
+
runTool: async (call) => {
|
|
722
|
+
// end_call and emote are the page's own tools. The line goes down once
|
|
723
|
+
// her goodbye is over (leave), so this reply reaches the model first
|
|
724
|
+
if (call.name === TOOL_NAMES.end_call) {
|
|
725
|
+
leave();
|
|
726
|
+
return "Ending the call.";
|
|
727
|
+
}
|
|
728
|
+
// the face draws the word; nothing runs anywhere else
|
|
729
|
+
if (call.name === TOOL_NAMES.emote) {
|
|
730
|
+
const { word, reply } = readFaceWord(call.arguments);
|
|
731
|
+
if (word) setFaceWord({ text: word, at: Date.now() });
|
|
732
|
+
return reply;
|
|
733
|
+
}
|
|
734
|
+
showTool(call);
|
|
735
|
+
try {
|
|
736
|
+
return await runRemoteTool(line.callId, call, stop.signal);
|
|
737
|
+
} finally {
|
|
738
|
+
hideTool(call.id);
|
|
739
|
+
}
|
|
740
|
+
},
|
|
741
|
+
reasoning: (part) => {
|
|
742
|
+
// A summary part opens with its title in bold; the activity line says what the work is about
|
|
743
|
+
const title = /^\s*\*\*(.+?)\*\*/.exec(part.text)?.[1]?.trim();
|
|
744
|
+
if (title) setThinkingTitle(title);
|
|
745
|
+
persist(
|
|
746
|
+
thinkingSaves,
|
|
747
|
+
() =>
|
|
748
|
+
saveThoughtAction(line.callId, {
|
|
749
|
+
...part,
|
|
750
|
+
seq: Math.max(0, Math.round(part.seq)),
|
|
751
|
+
}),
|
|
752
|
+
"The backend's thinking is not being saved",
|
|
734
753
|
);
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
754
|
+
},
|
|
755
|
+
turn: (turn) => {
|
|
756
|
+
// New words, not a checkpoint of the same ones. A cough or a sigh is not words:
|
|
757
|
+
// it neither holds the line open nor holds an update back
|
|
758
|
+
const words = turn.role === "tool" ? "" : spokenWords(turn.text);
|
|
759
|
+
const fresh =
|
|
760
|
+
Boolean(words) &&
|
|
761
|
+
words !== spokenWords(turns.get(turn.id)?.text ?? "");
|
|
762
|
+
// New words from either side restart the relay's quiet clock
|
|
763
|
+
if (fresh) heard.current = Date.now();
|
|
764
|
+
// Her voice and backend work rewind the idle clock from activity, where an
|
|
765
|
+
// update she reads out is told apart; transcripts lag the audio and cannot
|
|
766
|
+
if (fresh && turn.role === "user") stirred.current = Date.now();
|
|
767
|
+
// Tool turns are saved but not shown. Hanging up clears the screen
|
|
768
|
+
// before `close()` checkpoints open groups, so only the live call draws.
|
|
769
|
+
if (turn.role !== "tool" && callId.current === line.callId) {
|
|
770
|
+
turns.set(turn.id, {
|
|
771
|
+
seq: turn.seq,
|
|
772
|
+
id: turn.id,
|
|
773
|
+
role: turn.role,
|
|
774
|
+
text: turn.text,
|
|
775
|
+
});
|
|
776
|
+
setMessages(
|
|
777
|
+
[...turns.values()]
|
|
778
|
+
.sort((a, b) => a.seq - b.seq)
|
|
779
|
+
.slice(-KEEP_MESSAGES),
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
if (turn.done) {
|
|
783
|
+
persist(saving, () =>
|
|
784
|
+
saveTurnsAction(line.callId, [
|
|
785
|
+
{
|
|
786
|
+
id: turn.id,
|
|
787
|
+
role: turn.role,
|
|
788
|
+
tool: turn.tool,
|
|
789
|
+
text: turn.text,
|
|
790
|
+
// Live orders by audio milliseconds; the row keeps a whole number
|
|
791
|
+
seq: Math.max(0, Math.round(turn.seq)),
|
|
792
|
+
fragments: turn.fragments ?? null,
|
|
793
|
+
},
|
|
794
|
+
]),
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
},
|
|
798
|
+
// session facts drive the face through showFace; the tool line is drawn by the tool itself
|
|
799
|
+
activity: (activity) => {
|
|
800
|
+
showFace(statusOf(activity));
|
|
801
|
+
const busy = activity.working || activity.tools.length > 0;
|
|
802
|
+
acting.current = busy;
|
|
803
|
+
// From the moment the backend picks the turn up until her first word
|
|
804
|
+
holdThinking(busy, activity.speaking);
|
|
805
|
+
if (activity.speaking) voiced.current = Date.now();
|
|
806
|
+
// Sound on the mic alone does not rewind the clock: a noisy room would
|
|
807
|
+
// keep a call open forever. Her words and backend work do, except an
|
|
808
|
+
// update she voices on her own: waiting results must not hold a call open.
|
|
809
|
+
if (
|
|
810
|
+
(activity.speaking && !reading.current.on) ||
|
|
811
|
+
activity.working ||
|
|
812
|
+
activity.tools.length
|
|
813
|
+
)
|
|
814
|
+
stirred.current = Date.now();
|
|
815
|
+
// An update counts as voiced once her voice has started and stopped
|
|
816
|
+
if (reading.current.on) {
|
|
817
|
+
if (activity.speaking) reading.current.spoke = true;
|
|
818
|
+
else if (reading.current.spoke) doneReading();
|
|
738
819
|
}
|
|
739
|
-
callId.current = handshake.callId;
|
|
740
|
-
line.callId = handshake.callId;
|
|
741
|
-
line.opening = handshake.opening;
|
|
742
|
-
return handshake.sdp;
|
|
743
820
|
},
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
// the backend calls tools; the page forwards them to the server
|
|
747
|
-
runTool: async (call) => {
|
|
748
|
-
// end_call is the page's only tool. The line goes down once her
|
|
749
|
-
// goodbye is over (leave), so this reply reaches the model first
|
|
750
|
-
if (call.name === TOOL_NAMES.end_call) {
|
|
751
|
-
leave();
|
|
752
|
-
return "Ending the call.";
|
|
753
|
-
}
|
|
754
|
-
showTool(call);
|
|
755
|
-
try {
|
|
756
|
-
return await runRemoteTool(line.callId, call, stop.signal);
|
|
757
|
-
} finally {
|
|
758
|
-
hideTool(call.id);
|
|
759
|
-
}
|
|
760
|
-
},
|
|
761
|
-
reasoning: (part) =>
|
|
762
|
-
persist(
|
|
763
|
-
thinkingSaves,
|
|
764
|
-
() =>
|
|
765
|
-
saveThoughtAction(line.callId, {
|
|
766
|
-
...part,
|
|
767
|
-
seq: Math.max(0, Math.round(part.seq)),
|
|
768
|
-
}),
|
|
769
|
-
"The backend's thinking is not being saved",
|
|
770
|
-
),
|
|
771
|
-
turn: (turn) => {
|
|
772
|
-
// New words, not a checkpoint of the same ones: a blank fragment is not words
|
|
773
|
-
const fresh =
|
|
774
|
-
turn.role !== "tool" &&
|
|
775
|
-
Boolean(turn.text.trim()) &&
|
|
776
|
-
turn.text !== turns.get(turn.id)?.text;
|
|
777
|
-
// New words from either side restart the relay's quiet clock
|
|
778
|
-
if (fresh) heard.current = Date.now();
|
|
779
|
-
// Her voice and backend work rewind the idle clock from activity, where an
|
|
780
|
-
// update she reads out is told apart; transcripts lag the audio and cannot
|
|
781
|
-
if (fresh && turn.role === "user") {
|
|
782
|
-
const said = userSaid.get(turn.id) ?? Date.now();
|
|
783
|
-
userSaid.set(turn.id, said);
|
|
784
|
-
stir("user", said);
|
|
785
|
-
}
|
|
786
|
-
// Tool turns are saved but not shown. Hanging up clears the screen
|
|
787
|
-
// before `close()` checkpoints open groups, so only the live call draws.
|
|
788
|
-
if (turn.role !== "tool" && callId.current === line.callId) {
|
|
789
|
-
turns.set(turn.id, {
|
|
790
|
-
seq: turn.seq,
|
|
791
|
-
id: turn.id,
|
|
792
|
-
role: turn.role,
|
|
793
|
-
text: turn.text,
|
|
794
|
-
});
|
|
795
|
-
setMessages(
|
|
796
|
-
[...turns.values()]
|
|
797
|
-
.sort((a, b) => a.seq - b.seq)
|
|
798
|
-
.slice(-KEEP_MESSAGES),
|
|
799
|
-
);
|
|
800
|
-
}
|
|
801
|
-
if (turn.done) {
|
|
802
|
-
persist(saving, () =>
|
|
803
|
-
saveTurnsAction(line.callId, [
|
|
804
|
-
{
|
|
805
|
-
id: turn.id,
|
|
806
|
-
role: turn.role,
|
|
807
|
-
tool: turn.tool,
|
|
808
|
-
text: turn.text,
|
|
809
|
-
// Live orders by audio milliseconds; the row keeps a whole number
|
|
810
|
-
seq: Math.max(0, Math.round(turn.seq)),
|
|
811
|
-
fragments: turn.fragments ?? null,
|
|
812
|
-
},
|
|
813
|
-
]),
|
|
814
|
-
);
|
|
815
|
-
}
|
|
816
|
-
},
|
|
817
|
-
// session facts drive the face through showFace; the tool line is drawn by the tool itself
|
|
818
|
-
activity: (activity) => {
|
|
819
|
-
showFace(statusOf(activity));
|
|
820
|
-
const busy = activity.working || activity.tools.length > 0;
|
|
821
|
-
acting.current = busy;
|
|
822
|
-
// From the moment the backend picks the turn up until her first word
|
|
823
|
-
holdThinking(busy, activity.speaking);
|
|
824
|
-
if (activity.speaking) voiced.current = Date.now();
|
|
825
|
-
// Sound on the mic alone does not rewind the clock: a noisy room would
|
|
826
|
-
// keep a call open forever. Her words and backend work do, except an
|
|
827
|
-
// update she voices on her own: waiting results must not hold a call open.
|
|
828
|
-
if (
|
|
829
|
-
(activity.speaking && !reading.current.on) ||
|
|
830
|
-
activity.working ||
|
|
831
|
-
activity.tools.length
|
|
832
|
-
)
|
|
833
|
-
stir("agent");
|
|
834
|
-
// An update counts as voiced once her voice has started and stopped
|
|
835
|
-
if (reading.current.on) {
|
|
836
|
-
if (activity.speaking) reading.current.spoke = true;
|
|
837
|
-
else if (reading.current.spoke) doneReading();
|
|
838
|
-
}
|
|
839
|
-
},
|
|
840
|
-
finalized: (close) => {
|
|
841
|
-
finalized.current = close;
|
|
842
|
-
},
|
|
843
|
-
warn: (description) =>
|
|
844
|
-
toast.add({
|
|
845
|
-
type: "warning",
|
|
846
|
-
title: "Call warning",
|
|
847
|
-
description,
|
|
848
|
-
}),
|
|
849
|
-
failed: (description) => {
|
|
850
|
-
toast.add({ type: "error", title: "Call failed", description });
|
|
851
|
-
showFailed(true);
|
|
852
|
-
void hangUp();
|
|
853
|
-
},
|
|
821
|
+
finalized: (close) => {
|
|
822
|
+
finalized.current = close;
|
|
854
823
|
},
|
|
855
|
-
|
|
824
|
+
warn: (description) =>
|
|
825
|
+
toast.add({
|
|
826
|
+
type: "warning",
|
|
827
|
+
title: "Call warning",
|
|
828
|
+
description,
|
|
829
|
+
}),
|
|
830
|
+
failed: (description) => {
|
|
831
|
+
toast.add({ type: "error", title: "Call failed", description });
|
|
832
|
+
showFailed(true);
|
|
833
|
+
// Live says session.closed (finalized) when it closes the call itself
|
|
834
|
+
void hangUp(finalized.current ? "closed" : "dropped");
|
|
835
|
+
},
|
|
836
|
+
},
|
|
837
|
+
});
|
|
856
838
|
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
839
|
+
if (!current()) {
|
|
840
|
+
// Hung up while the line was going up; hangUp already ended the row
|
|
841
|
+
await live.close();
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
862
844
|
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
session.current = live;
|
|
869
|
-
opening.current = false;
|
|
870
|
-
rang.current = calledBack;
|
|
871
|
-
// Context is not gated: held lines first, then each as it comes
|
|
872
|
-
outbox.open((text) => void live.append("thinking", text));
|
|
873
|
-
// The quiet clock starts with the line, so nothing is put to her the moment it opens
|
|
874
|
-
heard.current = Date.now();
|
|
875
|
-
|
|
876
|
-
if (line.opening) {
|
|
877
|
-
// The greeting goes first; open work waits until she has said it
|
|
878
|
-
readAloud();
|
|
879
|
-
void live.append("instructions", line.opening).then(unless);
|
|
880
|
-
}
|
|
845
|
+
/** An opening that never landed leaves nothing to wait for. */
|
|
846
|
+
const unless = (delivered: boolean) => {
|
|
847
|
+
if (!delivered) doneReading();
|
|
848
|
+
};
|
|
881
849
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
showFailed(true);
|
|
895
|
-
}
|
|
896
|
-
if (attempt.current !== mine) return;
|
|
897
|
-
// a call that never opened still has a row; close it
|
|
898
|
-
if (callId.current) void endCallAction(callId.current);
|
|
899
|
-
callId.current = null;
|
|
900
|
-
opening.current = false;
|
|
901
|
-
calling.current = false;
|
|
902
|
-
working.current = null;
|
|
903
|
-
outbox.clear();
|
|
904
|
-
setStatus("idle");
|
|
850
|
+
session.current = live;
|
|
851
|
+
opening.current = false;
|
|
852
|
+
rang.current = calledBack;
|
|
853
|
+
// Context is not gated: held lines first, then each as it comes
|
|
854
|
+
outbox.open((text) => void live.append("thinking", text));
|
|
855
|
+
// The quiet clock starts with the line, so nothing is put to her the moment it opens
|
|
856
|
+
heard.current = Date.now();
|
|
857
|
+
|
|
858
|
+
if (line.opening) {
|
|
859
|
+
// The greeting goes first; open work waits until she has said it
|
|
860
|
+
readAloud();
|
|
861
|
+
void live.append("instructions", line.opening).then(unless);
|
|
905
862
|
}
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
863
|
+
|
|
864
|
+
wearFace("listening");
|
|
865
|
+
setSince(Date.now());
|
|
866
|
+
// a rejected chime is not worth a message
|
|
867
|
+
void chime.play().catch(() => {});
|
|
868
|
+
} catch (cause) {
|
|
869
|
+
// Hanging up while the line was going up is not a failure to report
|
|
870
|
+
if (current()) {
|
|
871
|
+
toast.add({
|
|
872
|
+
type: "error",
|
|
873
|
+
title: "Could not start the call",
|
|
874
|
+
description: errorToString(cause),
|
|
875
|
+
});
|
|
876
|
+
showFailed(true);
|
|
877
|
+
}
|
|
878
|
+
if (attempt.current !== mine) return;
|
|
879
|
+
// a call that never opened still has a row; close it
|
|
880
|
+
if (callId.current) void endCallAction(callId.current);
|
|
881
|
+
callId.current = null;
|
|
882
|
+
opening.current = false;
|
|
883
|
+
calling.current = false;
|
|
884
|
+
working.current = null;
|
|
885
|
+
outbox.clear();
|
|
886
|
+
setStatus("idle");
|
|
887
|
+
}
|
|
888
|
+
}, [
|
|
889
|
+
status,
|
|
890
|
+
hangUp,
|
|
891
|
+
showTool,
|
|
892
|
+
hideTool,
|
|
893
|
+
showFace,
|
|
894
|
+
wearFace,
|
|
895
|
+
outbox,
|
|
896
|
+
readAloud,
|
|
897
|
+
doneReading,
|
|
898
|
+
holdThinking,
|
|
899
|
+
showFailed,
|
|
900
|
+
leave,
|
|
901
|
+
isRinging,
|
|
902
|
+
]);
|
|
923
903
|
|
|
924
904
|
/**
|
|
925
|
-
* Call-back:
|
|
926
|
-
*
|
|
927
|
-
*
|
|
905
|
+
* Call-back: rings when a thread is waiting on the user and no line is open,
|
|
906
|
+
* only for threads that changed after `ringAfter`. What came up during a call
|
|
907
|
+
* was that call's to tell, and the next call tells what it left unsaid. Ringing
|
|
908
|
+
* never opens the line; answering does (`call`). A thread handled meanwhile, on
|
|
909
|
+
* screen or by the setting going off, stops ringing for itself.
|
|
928
910
|
*/
|
|
929
911
|
const callBack = useThursdayStore((state) => state.callBack);
|
|
930
|
-
const woke = useRef<Set<string> | null>(null);
|
|
931
912
|
useEffect(() => {
|
|
932
913
|
if (!threads) return;
|
|
933
|
-
const
|
|
914
|
+
const wanted = new Set(
|
|
915
|
+
threads
|
|
916
|
+
.filter((thread) => ringsFor(thread, callBack))
|
|
917
|
+
.map((thread) => thread.id),
|
|
918
|
+
);
|
|
919
|
+
setRingingFor((ids) =>
|
|
920
|
+
ids.every((id) => wanted.has(id))
|
|
921
|
+
? ids
|
|
922
|
+
: ids.filter((id) => wanted.has(id)),
|
|
923
|
+
);
|
|
924
|
+
if (status !== "idle") return;
|
|
925
|
+
const after = ringAfter.current;
|
|
926
|
+
const fresh = threads.filter(
|
|
934
927
|
(thread) =>
|
|
935
|
-
|
|
936
|
-
!thread.ask?.auto &&
|
|
937
|
-
(asksSomething(thread) ||
|
|
938
|
-
(callBack === "any" && thread.status !== "running")),
|
|
928
|
+
wanted.has(thread.id) && toDate(thread.updatedAt).getTime() > after,
|
|
939
929
|
);
|
|
930
|
+
if (!fresh.length) return;
|
|
940
931
|
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
const open = new Set(wants.map((thread) => thread.id));
|
|
948
|
-
for (const id of woke.current) {
|
|
949
|
-
if (!open.has(id)) woke.current.delete(id);
|
|
950
|
-
}
|
|
932
|
+
ringAfter.current = Date.now();
|
|
933
|
+
setRingingFor((ids) => [
|
|
934
|
+
...ids,
|
|
935
|
+
...fresh.map((thread) => thread.id).filter((id) => !ids.includes(id)),
|
|
936
|
+
]);
|
|
937
|
+
}, [threads, callBack, status]);
|
|
951
938
|
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
939
|
+
// While ringing: it stops by itself after CALL_BACK.ringMs, and Esc declines.
|
|
940
|
+
// A thread added to a ring already going does not restart the clock
|
|
941
|
+
useEffect(() => {
|
|
942
|
+
if (!isRinging) return;
|
|
943
|
+
const out = setTimeout(() => setRingingFor([]), CALL_BACK.ringMs);
|
|
944
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
945
|
+
if (event.key === "Escape" && !event.defaultPrevented) setRingingFor([]);
|
|
946
|
+
};
|
|
947
|
+
window.addEventListener("keydown", onKeyDown);
|
|
948
|
+
return () => {
|
|
949
|
+
clearTimeout(out);
|
|
950
|
+
window.removeEventListener("keydown", onKeyDown);
|
|
951
|
+
};
|
|
952
|
+
}, [isRinging]);
|
|
955
953
|
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
954
|
+
const decline = useCallback(() => setRingingFor([]), []);
|
|
955
|
+
/** What the ringing screen names: the first thread that rang, and how many rang with it. */
|
|
956
|
+
const ringing = useMemo((): Ringing | null => {
|
|
957
|
+
const rung = ringingFor.flatMap(
|
|
958
|
+
(id) => threads?.find((thread) => thread.id === id) ?? [],
|
|
959
|
+
);
|
|
960
|
+
const first = rung[0];
|
|
961
|
+
if (!first) return null;
|
|
962
|
+
const question = first.room.questions[0];
|
|
963
|
+
return {
|
|
964
|
+
bot: question?.bot ?? first.bot,
|
|
965
|
+
kind: question
|
|
966
|
+
? "question"
|
|
967
|
+
: first.status === "done"
|
|
968
|
+
? "done"
|
|
969
|
+
: "stopped",
|
|
970
|
+
label: first.label,
|
|
971
|
+
more: rung.length - 1,
|
|
972
|
+
};
|
|
973
|
+
}, [ringingFor, threads]);
|
|
959
974
|
|
|
960
975
|
/** Read by the face once per animation frame, outside React state. */
|
|
961
976
|
const getSpectrum = useCallback(() => tap.current?.read() ?? EMPTY_BANDS, []);
|
|
@@ -993,13 +1008,23 @@ export function useThursday() {
|
|
|
993
1008
|
failed,
|
|
994
1009
|
messages,
|
|
995
1010
|
tool,
|
|
1011
|
+
/** Why the last call ended, when the user did not end it; null during a call. */
|
|
1012
|
+
ended,
|
|
996
1013
|
/** When the backend picked the turn up (ms); null when it is not working. */
|
|
997
1014
|
thinkingSince,
|
|
1015
|
+
/** What the backend's latest reasoning summary says it is doing; null before one arrives. */
|
|
1016
|
+
thinkingTitle,
|
|
1017
|
+
/** The word `emote` last put on the face; null before one. */
|
|
1018
|
+
faceWord,
|
|
998
1019
|
/** Seconds until idle hang-up; null outside the warning window. */
|
|
999
1020
|
idleLeft,
|
|
1000
1021
|
/** When the line opened (ms); null without a call. */
|
|
1001
1022
|
since,
|
|
1002
1023
|
call,
|
|
1024
|
+
/** A call-back ringing; null when none is. Any way of placing a call answers it. */
|
|
1025
|
+
ringing,
|
|
1026
|
+
/** Stops the ringing without answering. */
|
|
1027
|
+
decline,
|
|
1003
1028
|
getSpectrum,
|
|
1004
1029
|
getMicSpectrum,
|
|
1005
1030
|
/** null when the tap is the only entry point. */
|
|
@@ -1011,6 +1036,36 @@ export function useThursday() {
|
|
|
1011
1036
|
|
|
1012
1037
|
const EMPTY_BANDS = new Array<number>(SPECTRUM_BANDS).fill(0);
|
|
1013
1038
|
|
|
1039
|
+
/** What `emote` asked the face to show, and the line the model reads back; `word` is null when nothing is shown. */
|
|
1040
|
+
function readFaceWord(args: string): { word: string | null; reply: string } {
|
|
1041
|
+
let text = "";
|
|
1042
|
+
try {
|
|
1043
|
+
const parsed: unknown = JSON.parse(args);
|
|
1044
|
+
if (parsed && typeof parsed === "object" && "text" in parsed) {
|
|
1045
|
+
text = String(parsed.text ?? "").trim();
|
|
1046
|
+
}
|
|
1047
|
+
} catch {
|
|
1048
|
+
text = "";
|
|
1049
|
+
}
|
|
1050
|
+
const length = Array.from(text).length;
|
|
1051
|
+
if (!length)
|
|
1052
|
+
return { word: null, reply: "Nothing was shown: text is empty." };
|
|
1053
|
+
if (length > FACE_WORD_MAX) {
|
|
1054
|
+
return {
|
|
1055
|
+
word: null,
|
|
1056
|
+
reply: `Too long: ${length} characters, ${FACE_WORD_MAX} at most. Nothing was shown.`,
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
const bad = undrawable(text);
|
|
1060
|
+
if (bad) {
|
|
1061
|
+
return {
|
|
1062
|
+
word: null,
|
|
1063
|
+
reply: `Cannot draw "${bad}"; use only the characters \`text\` lists. Nothing was shown.`,
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
return { word: text.toUpperCase(), reply: "Shown." };
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1014
1069
|
/**
|
|
1015
1070
|
* Calls opened without a gesture (wake word, call-back) get a suspended
|
|
1016
1071
|
* AudioContext. Resume on the next pointer or key event and say so on screen.
|
|
@@ -1052,6 +1107,15 @@ function statusOf(activity: LiveActivity): LiveStatus {
|
|
|
1052
1107
|
return "listening";
|
|
1053
1108
|
}
|
|
1054
1109
|
|
|
1110
|
+
/**
|
|
1111
|
+
* The words in a transcript, without the sounds Live writes in brackets ("[clear throat]",
|
|
1112
|
+
* "[sigh]"); a bracket may close in the next group ("] okay"). Empty when nothing was said.
|
|
1113
|
+
*/
|
|
1114
|
+
function spokenWords(text: string) {
|
|
1115
|
+
const words = text.replace(/\[[^\]]*\]?|\]/g, "").trim();
|
|
1116
|
+
return /[\p{L}\p{N}]/u.test(words) ? words : "";
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1055
1119
|
/** Fire-and-forget save; after SAVE_FAILURE_LIMIT failures of this kind it stops and says so once. */
|
|
1056
1120
|
function persist(
|
|
1057
1121
|
saving: { failures: number },
|
|
@@ -1073,10 +1137,15 @@ function persist(
|
|
|
1073
1137
|
}
|
|
1074
1138
|
|
|
1075
1139
|
/**
|
|
1076
|
-
*
|
|
1077
|
-
*
|
|
1140
|
+
* Whether a thread is one the call-back rings for: a real question, or with
|
|
1141
|
+
* "any" every ending. A job the app stops and picks up by itself never rings.
|
|
1078
1142
|
*/
|
|
1079
|
-
const
|
|
1143
|
+
const ringsFor = (thread: Thread, callBack: CallBack) =>
|
|
1144
|
+
callBack !== "off" &&
|
|
1145
|
+
!thread.seen &&
|
|
1146
|
+
!thread.ask?.auto &&
|
|
1147
|
+
(thread.room.questions.length > 0 ||
|
|
1148
|
+
(callBack === "any" && thread.status !== "running"));
|
|
1080
1149
|
|
|
1081
1150
|
/** One piece of background work waiting on the user, as the relay clock puts it to her. */
|
|
1082
1151
|
type OpenWork = {
|