@hanzo/rooms 0.1.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/LICENSE-APACHE +205 -0
- package/LICENSE-MIT +21 -0
- package/README.md +23 -0
- package/dist/Account.d.ts +8 -0
- package/dist/Account.js +58 -0
- package/dist/Beluga.d.ts +12 -0
- package/dist/Beluga.js +152 -0
- package/dist/Billing.d.ts +1 -0
- package/dist/Billing.js +31 -0
- package/dist/Board.d.ts +9 -0
- package/dist/Board.js +447 -0
- package/dist/Boxes.d.ts +1 -0
- package/dist/Boxes.js +173 -0
- package/dist/CalScheduler.d.ts +15 -0
- package/dist/CalScheduler.js +196 -0
- package/dist/Chat.d.ts +35 -0
- package/dist/Chat.js +1049 -0
- package/dist/Column.d.ts +15 -0
- package/dist/Column.js +88 -0
- package/dist/Contacts.d.ts +1 -0
- package/dist/Contacts.js +301 -0
- package/dist/Directory.d.ts +24 -0
- package/dist/Directory.js +356 -0
- package/dist/Drive.d.ts +2 -0
- package/dist/Drive.js +466 -0
- package/dist/Find.d.ts +7 -0
- package/dist/Find.js +82 -0
- package/dist/Guide.d.ts +6 -0
- package/dist/Guide.js +272 -0
- package/dist/Home.d.ts +39 -0
- package/dist/Home.js +306 -0
- package/dist/Inbox.d.ts +9 -0
- package/dist/Inbox.js +193 -0
- package/dist/Market.d.ts +3 -0
- package/dist/Market.js +149 -0
- package/dist/Me.d.ts +27 -0
- package/dist/Me.js +251 -0
- package/dist/Meet.d.ts +9 -0
- package/dist/Meet.js +370 -0
- package/dist/ModeMark.d.ts +8 -0
- package/dist/ModeMark.js +18 -0
- package/dist/OrgSwitcher.d.ts +31 -0
- package/dist/OrgSwitcher.js +120 -0
- package/dist/Orgs.d.ts +10 -0
- package/dist/Orgs.js +582 -0
- package/dist/Profile.d.ts +4 -0
- package/dist/Profile.js +67 -0
- package/dist/Prose.d.ts +5 -0
- package/dist/Prose.js +111 -0
- package/dist/ProviderMark.d.ts +17 -0
- package/dist/ProviderMark.js +277 -0
- package/dist/Recents.d.ts +10 -0
- package/dist/Recents.js +91 -0
- package/dist/RightPane.d.ts +13 -0
- package/dist/RightPane.js +426 -0
- package/dist/Room.d.ts +8 -0
- package/dist/Room.js +50 -0
- package/dist/Scene.d.ts +12 -0
- package/dist/Scene.js +105 -0
- package/dist/Settings.d.ts +46 -0
- package/dist/Settings.js +321 -0
- package/dist/Setup.d.ts +1 -0
- package/dist/Setup.js +36 -0
- package/dist/Share.d.ts +3 -0
- package/dist/Share.js +130 -0
- package/dist/Shared.d.ts +1 -0
- package/dist/Shared.js +113 -0
- package/dist/Shell.d.ts +63 -0
- package/dist/Shell.js +1225 -0
- package/dist/Start.d.ts +1 -0
- package/dist/Start.js +96 -0
- package/dist/Support.d.ts +14 -0
- package/dist/Support.js +122 -0
- package/dist/Talk.d.ts +4 -0
- package/dist/Talk.js +107 -0
- package/dist/Upgrade.d.ts +11 -0
- package/dist/Upgrade.js +75 -0
- package/dist/Usage.d.ts +1 -0
- package/dist/Usage.js +21 -0
- package/dist/Vibe.d.ts +2 -0
- package/dist/Vibe.js +188 -0
- package/dist/Work.d.ts +12 -0
- package/dist/Work.js +256 -0
- package/dist/bots/Bots.d.ts +2 -0
- package/dist/bots/Bots.js +456 -0
- package/dist/bots/Lights.d.ts +8 -0
- package/dist/bots/Lights.js +13 -0
- package/dist/bots/api.d.ts +120 -0
- package/dist/bots/api.js +75 -0
- package/dist/bots/face.d.ts +5 -0
- package/dist/bots/face.js +13 -0
- package/dist/bots/ink.d.ts +7 -0
- package/dist/bots/ink.js +29 -0
- package/dist/bots/kit.d.ts +113 -0
- package/dist/bots/kit.js +43 -0
- package/dist/bots/runtime.d.ts +4 -0
- package/dist/bots/runtime.js +326 -0
- package/dist/card.d.ts +52 -0
- package/dist/card.js +52 -0
- package/dist/cast.d.ts +143 -0
- package/dist/cast.js +251 -0
- package/dist/connect.d.ts +29 -0
- package/dist/connect.js +64 -0
- package/dist/contacts-store.d.ts +111 -0
- package/dist/contacts-store.js +276 -0
- package/dist/conversations.d.ts +54 -0
- package/dist/conversations.js +98 -0
- package/dist/copy.d.ts +23 -0
- package/dist/copy.js +39 -0
- package/dist/crew.d.ts +36 -0
- package/dist/crew.js +304 -0
- package/dist/dev.d.ts +3 -0
- package/dist/dev.js +102 -0
- package/dist/enso.d.ts +70 -0
- package/dist/enso.js +112 -0
- package/dist/failure.d.ts +57 -0
- package/dist/failure.js +143 -0
- package/dist/ground.d.ts +32 -0
- package/dist/ground.js +32 -0
- package/dist/host.d.ts +76 -0
- package/dist/host.js +76 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +12 -0
- package/dist/kept.d.ts +5 -0
- package/dist/kept.js +27 -0
- package/dist/lib/account.d.ts +49 -0
- package/dist/lib/account.js +229 -0
- package/dist/lib/ai.d.ts +46 -0
- package/dist/lib/ai.js +140 -0
- package/dist/lib/api.d.ts +43 -0
- package/dist/lib/api.js +62 -0
- package/dist/lib/bare.d.ts +9 -0
- package/dist/lib/bare.js +7 -0
- package/dist/lib/coding.d.ts +105 -0
- package/dist/lib/coding.js +87 -0
- package/dist/lib/destination.d.ts +102 -0
- package/dist/lib/destination.js +260 -0
- package/dist/lib/durable.d.ts +66 -0
- package/dist/lib/durable.js +107 -0
- package/dist/lib/first.d.ts +11 -0
- package/dist/lib/first.js +23 -0
- package/dist/lib/home.d.ts +61 -0
- package/dist/lib/home.js +54 -0
- package/dist/lib/host.d.ts +104 -0
- package/dist/lib/host.js +132 -0
- package/dist/lib/hostname.d.ts +1 -0
- package/dist/lib/hostname.js +14 -0
- package/dist/lib/hydrated.d.ts +1 -0
- package/dist/lib/hydrated.js +20 -0
- package/dist/lib/integrations.d.ts +21 -0
- package/dist/lib/integrations.js +1 -0
- package/dist/lib/intent.d.ts +6 -0
- package/dist/lib/intent.js +71 -0
- package/dist/lib/invite.d.ts +155 -0
- package/dist/lib/invite.js +245 -0
- package/dist/lib/limits.d.ts +55 -0
- package/dist/lib/limits.js +119 -0
- package/dist/lib/list.d.ts +9 -0
- package/dist/lib/list.js +11 -0
- package/dist/lib/mark.d.ts +13 -0
- package/dist/lib/mark.js +85 -0
- package/dist/lib/mix.d.ts +22 -0
- package/dist/lib/mix.js +22 -0
- package/dist/lib/models.d.ts +27 -0
- package/dist/lib/models.js +106 -0
- package/dist/lib/org.d.ts +109 -0
- package/dist/lib/org.js +140 -0
- package/dist/lib/path.d.ts +9 -0
- package/dist/lib/path.js +29 -0
- package/dist/lib/plans.d.ts +193 -0
- package/dist/lib/plans.js +310 -0
- package/dist/lib/prose.d.ts +13 -0
- package/dist/lib/prose.js +62 -0
- package/dist/lib/reach.d.ts +17 -0
- package/dist/lib/reach.js +70 -0
- package/dist/lib/rooms.json +23 -0
- package/dist/lib/served.d.ts +20 -0
- package/dist/lib/served.js +92 -0
- package/dist/lib/session.d.ts +211 -0
- package/dist/lib/session.js +493 -0
- package/dist/lib/share.d.ts +131 -0
- package/dist/lib/share.js +216 -0
- package/dist/lib/signout.d.ts +14 -0
- package/dist/lib/signout.js +40 -0
- package/dist/lib/tags.d.ts +27 -0
- package/dist/lib/tags.js +46 -0
- package/dist/lib/tap.d.ts +17 -0
- package/dist/lib/tap.js +9 -0
- package/dist/lib/tier.d.ts +115 -0
- package/dist/lib/tier.js +207 -0
- package/dist/lib/tiers.d.ts +9 -0
- package/dist/lib/tiers.js +141 -0
- package/dist/lib/todo.d.ts +125 -0
- package/dist/lib/todo.js +379 -0
- package/dist/look.d.ts +1 -0
- package/dist/look.js +21 -0
- package/dist/meters.d.ts +8 -0
- package/dist/meters.js +31 -0
- package/dist/model.d.ts +4 -0
- package/dist/model.js +44 -0
- package/dist/network.d.ts +64 -0
- package/dist/network.js +216 -0
- package/dist/open.d.ts +81 -0
- package/dist/open.js +149 -0
- package/dist/pane.d.ts +92 -0
- package/dist/pane.js +231 -0
- package/dist/progress.d.ts +46 -0
- package/dist/progress.js +51 -0
- package/dist/recent.d.ts +67 -0
- package/dist/recent.js +161 -0
- package/dist/rooms.d.ts +131 -0
- package/dist/rooms.js +195 -0
- package/dist/roster.d.ts +28 -0
- package/dist/roster.js +70 -0
- package/dist/span.d.ts +14 -0
- package/dist/span.js +50 -0
- package/dist/speech.d.ts +44 -0
- package/dist/speech.js +229 -0
- package/dist/stars.d.ts +16 -0
- package/dist/stars.js +75 -0
- package/dist/tabs.d.ts +26 -0
- package/dist/tabs.js +25 -0
- package/dist/team.d.ts +11 -0
- package/dist/team.gen.d.ts +10 -0
- package/dist/team.gen.js +11 -0
- package/dist/team.js +30 -0
- package/dist/tools.d.ts +35 -0
- package/dist/tools.js +143 -0
- package/dist/tours.d.ts +24 -0
- package/dist/tours.js +311 -0
- package/dist/waiting.d.ts +15 -0
- package/dist/waiting.js +17 -0
- package/dist/where.d.ts +54 -0
- package/dist/where.js +14 -0
- package/package.json +108 -0
package/dist/Chat.js
ADDED
|
@@ -0,0 +1,1049 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// /chat's pane: the conversation.
|
|
4
|
+
//
|
|
5
|
+
// `@hanzo/ai` holds the thread and `@hanzo/ui/chat` draws it — `<Chat {...useChat()} />`
|
|
6
|
+
// is one call by design, and the two packages were shaped to make exactly that
|
|
7
|
+
// call typecheck. Nothing about the conversation is written here.
|
|
8
|
+
//
|
|
9
|
+
// WHO CAN SEND: an account. `/v1/agents/chat` answers a signed-out caller
|
|
10
|
+
// "a validated principal is required" — measured on production, with and
|
|
11
|
+
// without the publishable key the bundle ships and with a browser's own Origin
|
|
12
|
+
// — so a visitor's first message comes back refused however the room is drawn.
|
|
13
|
+
// The room says that in the reader's words and offers the way past it; what it
|
|
14
|
+
// must not do is describe an account they do not have.
|
|
15
|
+
//
|
|
16
|
+
// The free MODEL is a separate question and still exists: `anonymous` opens on
|
|
17
|
+
// the free pool, so what changes at sign-in is the menu, not the room.
|
|
18
|
+
//
|
|
19
|
+
// The FREE lane is about PRICE, not identity: a free turn spends no balance and
|
|
20
|
+
// is data-shared in exchange, which is what the consent block asks about, at
|
|
21
|
+
// SEND rather than on arrival — consent is owed when someone sends, not when
|
|
22
|
+
// they walk in. Signing in is what buys the paid models and a history that
|
|
23
|
+
// follows you; it is never the price of asking a question.
|
|
24
|
+
//
|
|
25
|
+
// This file used to branch on `isAuthenticated` and hand a visitor a composer
|
|
26
|
+
// whose only action was a trip to hanzo.id, captioned "Sign in to send". The
|
|
27
|
+
// branch is gone. If the gateway refuses an anonymous free turn, that answer
|
|
28
|
+
// belongs in the thread where the reader can see it, not in a caption that
|
|
29
|
+
// pre-emptively refuses on the gateway's behalf.
|
|
30
|
+
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
31
|
+
import { capped, detail, familyOf, planRequired, plainly, refused } from "./failure.js";
|
|
32
|
+
import { Prose } from "./Prose.js";
|
|
33
|
+
import { useAgent, useAi, useChat, useModels, } from "@hanzo/ai/react";
|
|
34
|
+
import { freeCopy, grantConsent, hasConsent, isFree, } from "@hanzo/ai";
|
|
35
|
+
import { ASK, Chat as ChatSurface, Code, Failure } from "@hanzo/ui/chat";
|
|
36
|
+
import { Face, brief as roomBrief, join, roleOf, roomTurn, roster as rosterLine, rosterOf, speakers, speaksOf, voiceOf } from "./cast.js";
|
|
37
|
+
import { SpeakButton, speakAgent } from "./speech.js";
|
|
38
|
+
import { Crew, HOUSE, useCrew } from "./crew.js";
|
|
39
|
+
import { called } from "./team.js";
|
|
40
|
+
import { Anchor, Box, Button, Text, View, XStack, YStack } from "@hanzo/ui";
|
|
41
|
+
import { Control } from "@hanzo/composer";
|
|
42
|
+
import { useIam, useIamToken } from "@hanzo/iam/react";
|
|
43
|
+
import { hasSession, org } from "./lib/session.js";
|
|
44
|
+
import { clock, sku, useLimits } from "./lib/limits.js";
|
|
45
|
+
import { Meters } from "./meters.js";
|
|
46
|
+
import { useHydrated } from "./lib/hydrated.js";
|
|
47
|
+
import { base, served } from "./lib/ai.js";
|
|
48
|
+
import { onServed } from "./lib/served.js";
|
|
49
|
+
import { speech, useVoice, Voice } from "@hanzo/voice";
|
|
50
|
+
import { ArrowUp, Mic, Square, Star, PanelRight, X } from "lucide-react";
|
|
51
|
+
import { ENSO, FREE } from "./lib/ai.js";
|
|
52
|
+
import { openThread, useOpen } from "./open.js";
|
|
53
|
+
import { Beside, Framed } from "./Shell.js";
|
|
54
|
+
import { pane } from "./ground.js";
|
|
55
|
+
import { RightPane, usePinned } from "./RightPane.js";
|
|
56
|
+
import { Take } from "./copy.js";
|
|
57
|
+
import { Share } from "./Share.js";
|
|
58
|
+
import { useStarred } from "./stars.js";
|
|
59
|
+
import { useModel } from "./model.js";
|
|
60
|
+
import { Enso, nameOf, useEffort } from "./enso.js";
|
|
61
|
+
import { checkoutUrl, onFree, planName } from './lib/plans.js';
|
|
62
|
+
import { Upgrade, useFree } from './Upgrade.js';
|
|
63
|
+
import { enter, LOGIN } from './lib/destination.js';
|
|
64
|
+
import { first } from './lib/first.js';
|
|
65
|
+
import { mix } from "./lib/mix.js";
|
|
66
|
+
import { site } from './where.js';
|
|
67
|
+
/** The veil under the free lane's consent sheet. */
|
|
68
|
+
const SCRIM = mix("var(--pure-black)", 72, "srgb");
|
|
69
|
+
// NO REASONING PANEL HERE. Every assistant turn opened a "Thinking" disclosure
|
|
70
|
+
// listing seven steps — "Planning CSS extraction for chat styles", "Designing
|
|
71
|
+
// responsive top bar grid layout" — typed into this file as a literal array.
|
|
72
|
+
// They were the same seven under every answer in every conversation, and they
|
|
73
|
+
// described the writing of this component rather than anything a model did. A
|
|
74
|
+
// reasoning trace belongs to a turn; when the wire carries one it can be drawn
|
|
75
|
+
// from the turn, and until then the answer stands on its own.
|
|
76
|
+
//
|
|
77
|
+
// The queued-message box went with it: a second unmounted component, holding
|
|
78
|
+
// the file's last use of the chevrons imported for the panel.
|
|
79
|
+
/**
|
|
80
|
+
* The room's reading measure: the design system's prose width, which is what
|
|
81
|
+
* the Width setting (@hanzo/appearance) retunes. 48rem is 768px, the width this
|
|
82
|
+
* room has always read at, so an untouched setting changes nothing.
|
|
83
|
+
*/
|
|
84
|
+
const MEASURE = "var(--container-prose, 48rem)";
|
|
85
|
+
/**
|
|
86
|
+
* WHAT AN EMPTY ROOM OFFERS.
|
|
87
|
+
*
|
|
88
|
+
* A blank field asks the reader to invent the first move, and the commonest
|
|
89
|
+
* answer to "what can I help with?" is not knowing what to ask for. These are
|
|
90
|
+
* four shapes of question rather than four topics — read the code you have,
|
|
91
|
+
* find the fault in what it printed, plan the thing you have not built, and
|
|
92
|
+
* shorten something long — so between them they say what kind of help this is
|
|
93
|
+
* without claiming a skill nobody verified.
|
|
94
|
+
*
|
|
95
|
+
* Pressing one FILLS the field and does not send it. Each is the opening of a
|
|
96
|
+
* question the reader has to finish with their own material, and sending it as
|
|
97
|
+
* written would ask about nothing.
|
|
98
|
+
*/
|
|
99
|
+
const STARTERS = [
|
|
100
|
+
"Explain what this code does",
|
|
101
|
+
"Find the fault in this stack trace",
|
|
102
|
+
"Plan how to ship this feature",
|
|
103
|
+
"Summarise this document",
|
|
104
|
+
];
|
|
105
|
+
/**
|
|
106
|
+
* The store, or null when the browser will not give us one.
|
|
107
|
+
*
|
|
108
|
+
* Reading `window.localStorage` THROWS `SecurityError` where site data is
|
|
109
|
+
* blocked — third-party contexts, Safari's Block All Cookies, a hardened
|
|
110
|
+
* profile — and this is called from inside a send handler, so an unguarded
|
|
111
|
+
* access takes the send down rather than the storage.
|
|
112
|
+
*/
|
|
113
|
+
const store = () => {
|
|
114
|
+
if (typeof window === "undefined")
|
|
115
|
+
return null;
|
|
116
|
+
try {
|
|
117
|
+
return window.localStorage;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* The text this screen arrived with, TYPED INTO THE COMPOSER — never sent.
|
|
125
|
+
*
|
|
126
|
+
* `?q=` means "a question came with the page", and the previous answer to that
|
|
127
|
+
* was to send it on mount. That was wrong in a way one send did not fix: a link
|
|
128
|
+
* is not consent to spend. The
|
|
129
|
+
* question reached two routes (/chat and, once /models mounted `Ask`, that page
|
|
130
|
+
* too), so `hanzo.ai/models?q=…` was a URL that spent a stranger's credits on
|
|
131
|
+
* text a stranger chose and wrote it into their history. Prefilling removes the
|
|
132
|
+
* whole class rather than capping it at one — a human presses send, or nothing
|
|
133
|
+
* is spent.
|
|
134
|
+
*
|
|
135
|
+
* `q` is still CONSUMED from the address: it has done its job once it is in the
|
|
136
|
+
* box, and leaving it there means a reload re-types over whatever was written
|
|
137
|
+
* since.
|
|
138
|
+
*/
|
|
139
|
+
function useOpening() {
|
|
140
|
+
const [text, setText] = useState("");
|
|
141
|
+
const [thread, setThread] = useState(null);
|
|
142
|
+
const done = useRef(false);
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (done.current)
|
|
145
|
+
return;
|
|
146
|
+
done.current = true;
|
|
147
|
+
const url = new URL(window.location.href);
|
|
148
|
+
const asked = url.searchParams.get("q");
|
|
149
|
+
// A conversation carried in from hanzo.chat. The free chat records the thread
|
|
150
|
+
// under the reader's own account and sends its id, never the transcript; this
|
|
151
|
+
// room opens that thread once the reader is signed in. Struck from the bar
|
|
152
|
+
// like `?q=`, for the same reasons.
|
|
153
|
+
const carried = url.searchParams.get("thread");
|
|
154
|
+
if (asked !== null || carried !== null) {
|
|
155
|
+
url.searchParams.delete("q");
|
|
156
|
+
url.searchParams.delete("thread");
|
|
157
|
+
window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
|
|
158
|
+
}
|
|
159
|
+
const found = (asked || "").trim();
|
|
160
|
+
if (found)
|
|
161
|
+
setText(found);
|
|
162
|
+
const id = (carried || "").trim();
|
|
163
|
+
if (id)
|
|
164
|
+
setThread(id);
|
|
165
|
+
}, []);
|
|
166
|
+
return { text, thread };
|
|
167
|
+
}
|
|
168
|
+
/** Types an arriving question into a composer once it lands. */
|
|
169
|
+
function useSeed(initial, set) {
|
|
170
|
+
const apply = useRef(set);
|
|
171
|
+
apply.current = set;
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (initial)
|
|
174
|
+
apply.current(initial);
|
|
175
|
+
}, [initial]);
|
|
176
|
+
}
|
|
177
|
+
/** The circle both controls are cut from: @hanzo/composer's `Control`, at the
|
|
178
|
+
* 30px a line of this composer's footer row holds. */
|
|
179
|
+
const ROUND = 30;
|
|
180
|
+
export function Chat() {
|
|
181
|
+
const { isAuthenticated, isLoading } = useIam();
|
|
182
|
+
const hydrated = useHydrated();
|
|
183
|
+
// Read HERE, not in the child, and it stays here now that there is one child:
|
|
184
|
+
// two components consuming `?q=` meant whichever mounted first stripped it and
|
|
185
|
+
// the one that ended up on screen found nothing.
|
|
186
|
+
const { text: opening, thread: carried } = useOpening();
|
|
187
|
+
const settled = hydrated && !isLoading;
|
|
188
|
+
const signedIn = isAuthenticated || hasSession();
|
|
189
|
+
// The carried thread opens once there is somebody to open it for. Before
|
|
190
|
+
// sign-in it waits; `rememberDestination` already keeps `?thread=` through the
|
|
191
|
+
// trip to the issuer and back, so the id arrives here a second time and opens.
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
if (carried && settled && signedIn)
|
|
194
|
+
openThread(carried);
|
|
195
|
+
}, [carried, settled, signedIn]);
|
|
196
|
+
// The client is the SHELL's — one per room, so the thread and the roster share
|
|
197
|
+
// a session rather than each minting its own. `Thread` is therefore only ever
|
|
198
|
+
// mounted under the provider the shell already put above it.
|
|
199
|
+
//
|
|
200
|
+
// ONE room, for everyone. `anonymous` changes which models are on offer and
|
|
201
|
+
// nothing else — the composer, the consent and the transcript are the same
|
|
202
|
+
// components either way, because a visitor is having the same conversation.
|
|
203
|
+
//
|
|
204
|
+
// The conversations are NOT drawn here. `Threads` is the list and the shell
|
|
205
|
+
// owns the column it sits in — one sidebar for six rooms, already built, and a
|
|
206
|
+
// second one in this pane would be two designs for one job. This file draws
|
|
207
|
+
// the conversation; the shell draws the room.
|
|
208
|
+
return _jsx(Thread, { initial: opening, anonymous: settled && !signedIn });
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The same conversation, embedded in a page that is not /chat.
|
|
212
|
+
*
|
|
213
|
+
* R8: there were TWO live chat surfaces with different consent semantics — this
|
|
214
|
+
* one asks at SEND and fails closed, the other gated on ARRIVAL through a
|
|
215
|
+
* separate copy of the consent code. Two answers to one question is how they
|
|
216
|
+
* drift, so /models mounts this and the second surface is gone. It carries no
|
|
217
|
+
* heading of its own: the host page already has one, and a second `<h1>` is the
|
|
218
|
+
* defect that took the landings off /chat and /dev.
|
|
219
|
+
*
|
|
220
|
+
* The host supplies the `AiProvider`, because the host is what knows whether it
|
|
221
|
+
* already has one.
|
|
222
|
+
*/
|
|
223
|
+
export function Ask({ placeholder, idle, }) {
|
|
224
|
+
const { isAuthenticated, isLoading } = useIam();
|
|
225
|
+
const hydrated = useHydrated();
|
|
226
|
+
const { text: opening } = useOpening();
|
|
227
|
+
const signedIn = isAuthenticated || hasSession();
|
|
228
|
+
if (!(hydrated && !isLoading))
|
|
229
|
+
return _jsx(_Fragment, { children: idle });
|
|
230
|
+
return (_jsx(Thread, { placeholder: placeholder, initial: opening, anonymous: !signedIn, heading: null }));
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The resting placeholder.
|
|
234
|
+
*
|
|
235
|
+
* The package defaults to "Ask anything" and argues for it against
|
|
236
|
+
* "Message <model>" — a name that moves renames the control every time the
|
|
237
|
+
* model does. This is not that: it is the PRODUCT's name, one constant string,
|
|
238
|
+
* and it is already how this file addresses a character (`Message
|
|
239
|
+
* ${persona.name}`). `label` stays "Ask anything", so the accessible name does
|
|
240
|
+
* not move when the placeholder does.
|
|
241
|
+
*/
|
|
242
|
+
const MESSAGE = "Message Hanzo AI";
|
|
243
|
+
/** The id the room's system turn is held under in the thread. */
|
|
244
|
+
const ROOM = "room";
|
|
245
|
+
/** The models that route a turn rather than name the one that answers it. */
|
|
246
|
+
const ROUTERS = new Set(["enso", "enso-auto", "enso-free", "hanzo/enso", "auto", "free"]);
|
|
247
|
+
/** The id of the turn drawn in the thread when a turn is refused. */
|
|
248
|
+
const FAILED = "failed";
|
|
249
|
+
/** The turns with the room's system turn first, or without one for an empty room. */
|
|
250
|
+
function seated(turns, turn) {
|
|
251
|
+
const rest = turns.filter((one) => one.id !== ROOM);
|
|
252
|
+
return turn ? [{ id: ROOM, role: "system", content: turn }, ...rest] : rest;
|
|
253
|
+
}
|
|
254
|
+
/** An answer that came back with nothing in it. */
|
|
255
|
+
const blank = (one) => one.role === "assistant" && !String(one.content ?? "").trim() && !one.tool_calls?.length;
|
|
256
|
+
/**
|
|
257
|
+
* The agents a question speaks to: those it names, or the whole room when it
|
|
258
|
+
* names nobody — the rule the room's brief gives the model (cast.tsx `brief`).
|
|
259
|
+
*/
|
|
260
|
+
export function addressed(question, room) {
|
|
261
|
+
const named = room.filter((name) => new RegExp(`(^|[^\\p{L}\\p{N}])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}($|[^\\p{L}\\p{N}])`, "iu").test(question));
|
|
262
|
+
return named.length ? named : room;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Which kind of refusal a failed turn met, read off the platform's own codes:
|
|
266
|
+
* the caller's wallet (`insufficient_balance`, the ai service's spend check), a visitor's
|
|
267
|
+
* spent free messages (`public_allowance_spent`), or a model provider that did
|
|
268
|
+
* not answer (`providers_exhausted`, or any 5xx that is not the balance lookup).
|
|
269
|
+
*/
|
|
270
|
+
export function refusalOf(error) {
|
|
271
|
+
const status = error.status;
|
|
272
|
+
const body = error.body;
|
|
273
|
+
const codes = [body?.code, body?.error?.code, error.code].filter((one) => typeof one === "string");
|
|
274
|
+
const has = (code) => codes.includes(code);
|
|
275
|
+
return {
|
|
276
|
+
wallet: has("insufficient_balance"),
|
|
277
|
+
visitor: has("public_allowance_spent"),
|
|
278
|
+
provider: !has("balance_unavailable") &&
|
|
279
|
+
!has("insufficient_balance") &&
|
|
280
|
+
(has("providers_exhausted") || (typeof status === "number" && status >= 500)),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/** What the server said about a failure, folded under its plain line. */
|
|
284
|
+
function Why({ text }) {
|
|
285
|
+
const [open, setOpen] = useState(false);
|
|
286
|
+
if (!text)
|
|
287
|
+
return null;
|
|
288
|
+
return (_jsxs(YStack, { gap: "$1", children: [_jsx(XStack, { render: "button", "aria-expanded": open, onPress: () => setOpen((v) => !v), cursor: "pointer", children: _jsx(Text, { fontSize: "$2", color: "$soft", children: open ? "Hide details" : "Details" }) }), open ? (_jsx(Text, { fontSize: "$2", color: "$soft", children: text })) : null] }));
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* How long a turn may stream without a word before it is stopped and said to
|
|
292
|
+
* have failed. Long enough for a model that reasons at High effort before it
|
|
293
|
+
* writes, which streams no words while it does.
|
|
294
|
+
*/
|
|
295
|
+
const STALL = 300_000;
|
|
296
|
+
/**
|
|
297
|
+
* The conversation. One component for a visitor and for an account.
|
|
298
|
+
*
|
|
299
|
+
* `anonymous` opens on the free pool and offers only what the free pool can
|
|
300
|
+
* answer, because listing a paid model to someone with no balance is an offer
|
|
301
|
+
* that fails at send. Everything else — the transcript, the composer, the
|
|
302
|
+
* consent — is identical, which is the point: signing in later changes the
|
|
303
|
+
* models on the menu, not the room.
|
|
304
|
+
*/
|
|
305
|
+
function Thread({ placeholder = MESSAGE, initial, anonymous = false, heading, }) {
|
|
306
|
+
/**
|
|
307
|
+
* The model, and WHO decides it.
|
|
308
|
+
*
|
|
309
|
+
* `useState(anonymous ? FREE : ENSO)` read the flag ONCE, on the first render
|
|
310
|
+
* — which is the render before hydration settles, where `anonymous` is always
|
|
311
|
+
* false. So every visitor opened on ENSO while the composer beside them said
|
|
312
|
+
* "Enso Free", `isFree(model)` was false, and the two things that hang off it
|
|
313
|
+
* both went quiet: the data-sharing notice never appeared, and the consent
|
|
314
|
+
* block never asked. A visitor was sending on the paid pool having agreed to
|
|
315
|
+
* nothing, because a state initialiser cannot see a value that arrives later.
|
|
316
|
+
*
|
|
317
|
+
* A CHOICE, or the identity. `null` is "nobody has chosen", which is a
|
|
318
|
+
* different fact from either model and the only one that can defer to a flag
|
|
319
|
+
* that settles after mount. Picking from the menu still wins, permanently.
|
|
320
|
+
*/
|
|
321
|
+
const [asking, setAsking] = useState(null);
|
|
322
|
+
// STARRED HERE, READ IN THE COLUMN — the shape a room and a project already
|
|
323
|
+
// have. hanzo.chat called it a bookmark; the word this site uses is starred,
|
|
324
|
+
// and one word for one act is what keeps the three lists reading alike.
|
|
325
|
+
const [starred, star] = useStarred("threads");
|
|
326
|
+
// The setter is taken now, not just the value: the override moved from
|
|
327
|
+
// Settings into the composer's own panel, so this is where it is written.
|
|
328
|
+
const [preferred, setPreferred] = useModel();
|
|
329
|
+
const [effort, setEffort] = useEffort();
|
|
330
|
+
const ai = useAi();
|
|
331
|
+
// In the app's frame the composer floats on the pane as a pane of its own.
|
|
332
|
+
const framed = useContext(Framed);
|
|
333
|
+
const { thread: open, open: reopen, agents: room, openAgent, seat, emptied, aside, showAside, showSettings } = useOpen();
|
|
334
|
+
// The first of them: what a surface that addresses one person reads.
|
|
335
|
+
const withWhom = room[0] ?? null;
|
|
336
|
+
// Whether the column beside this conversation is showing its summary, and the
|
|
337
|
+
// switch for it. Kept per conversation by `pane.ts`, so the header's control
|
|
338
|
+
// reflects THIS room rather than the last one that was open.
|
|
339
|
+
const [pinned, flipPin] = usePinned();
|
|
340
|
+
/**
|
|
341
|
+
* WHO YOU ARE TALKING TO, when it is somebody rather than the plain model.
|
|
342
|
+
*
|
|
343
|
+
* Pressing a name in the roster selected a RUN, which this pane never reads,
|
|
344
|
+
* so the chat opened saying nothing about them. `agent` names them, the detail
|
|
345
|
+
* carries their `instructions`, and those instructions ARE the attachment:
|
|
346
|
+
* seeded as the thread's system turn they ride with every message on the wire,
|
|
347
|
+
* so the reply comes back in their voice rather than the house one. No second
|
|
348
|
+
* send path and no `preset` running parallel to `messages` — a conversation
|
|
349
|
+
* already has a place for who is speaking, and this is it.
|
|
350
|
+
*/
|
|
351
|
+
// THE COLUMN'S OWN LIST, because that is what draws the faces beside this
|
|
352
|
+
// room. `useAgent` asks the API for one agent BY REF and an agent is
|
|
353
|
+
// addressed here by the name a reader picked, so it answers null for every
|
|
354
|
+
// row whose id is not its name — which is why the hero had no picture and no
|
|
355
|
+
// sentence for an agent the column had just drawn a memoji for.
|
|
356
|
+
const { agents: roster, loading: gathering } = useCrew();
|
|
357
|
+
// THE HANDLE A NAME IS FILED UNDER. The room holds the names a person reads
|
|
358
|
+
// ("Dev"); the platform files a core member under its lower-case handle
|
|
359
|
+
// ("dev"), so a read goes out under the org's own spelling.
|
|
360
|
+
const handle = useCallback((name) => (Array.isArray(roster) ? roster : []).find((one) => one.name.toLowerCase() === name.toLowerCase())?.name ?? name, [roster]);
|
|
361
|
+
const { agent: read, loading: reading } = useAgent(withWhom && !gathering ? handle(withWhom) : null);
|
|
362
|
+
// THIS agent's record only: the hook keeps the last agent's row while it reads
|
|
363
|
+
// the next, so a row whose name is not the one in the room is not theirs.
|
|
364
|
+
const persona = read && withWhom && read.name?.toLowerCase() === withWhom.toLowerCase() ? read : null;
|
|
365
|
+
// Until the record answers, the model they are pinned to is unknown, so a send
|
|
366
|
+
// waits for it rather than going out on the reader's own model.
|
|
367
|
+
const resolving = Boolean(withWhom) && (gathering || reading);
|
|
368
|
+
const resolvingNow = useRef(resolving);
|
|
369
|
+
resolvingNow.current = resolving;
|
|
370
|
+
// THE ROOM'S FACES, by name: what the column's own list says each one looks
|
|
371
|
+
// like, which is what the header, the hero and a reply's byline all draw.
|
|
372
|
+
const faces = useMemo(() => {
|
|
373
|
+
const rows = Array.isArray(roster) ? roster : [];
|
|
374
|
+
return Object.fromEntries(room.map((name) => [name, rows.find((one) => one.name.toLowerCase() === name.toLowerCase())]));
|
|
375
|
+
}, [roster, room]);
|
|
376
|
+
const mate = withWhom ? faces[withWhom] : undefined;
|
|
377
|
+
// ONE SPELLING PER AGENT. A core member is named as hanzoai/personas names
|
|
378
|
+
// them whatever the org's handle ("des" is Des, `called`); any other agent is
|
|
379
|
+
// named as the org spells it, so a face pressed before the org's agents arrive
|
|
380
|
+
// is named again once they do, and its row, picture and brief are the org's.
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
const rows = Array.isArray(roster) ? roster : [];
|
|
383
|
+
const named = room.map((name) => called(rows.find((one) => one.name.toLowerCase() === name.toLowerCase())?.name ?? name));
|
|
384
|
+
if (named.some((name, i) => name !== room[i]))
|
|
385
|
+
seat(named);
|
|
386
|
+
}, [roster, room, seat]);
|
|
387
|
+
// WHO A REPLY MAY NAME: the room, the org's agents and the house crew. A turn
|
|
388
|
+
// said while someone was in the room stays split by speaker after they leave.
|
|
389
|
+
const known = useMemo(() => [...new Set([...room, ...(Array.isArray(roster) ? roster.map((one) => called(one.name)) : []), ...HOUSE])], [room, roster]);
|
|
390
|
+
// EVERYONE ELSE IN THE ROOM. `useAgent` reads the first by ref; the rest are
|
|
391
|
+
// read here once each and kept, so a member who steps out and back in does
|
|
392
|
+
// not cost a second read. A name the platform does not hold reads null and
|
|
393
|
+
// falls to what the cast says of them.
|
|
394
|
+
const [briefs, setBriefs] = useState({});
|
|
395
|
+
const asked = useRef(new Set());
|
|
396
|
+
useEffect(() => {
|
|
397
|
+
for (const name of room) {
|
|
398
|
+
const ref = handle(name);
|
|
399
|
+
if (name === withWhom || asked.current.has(ref))
|
|
400
|
+
continue;
|
|
401
|
+
asked.current.add(ref);
|
|
402
|
+
ai.agents
|
|
403
|
+
.get(ref)
|
|
404
|
+
.then((found) => setBriefs((all) => ({ ...all, [name]: found?.instructions?.trim() || null })))
|
|
405
|
+
.catch(() => {
|
|
406
|
+
asked.current.delete(ref);
|
|
407
|
+
setBriefs((all) => ({ ...all, [name]: null }));
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}, [ai, room, withWhom, handle]);
|
|
411
|
+
// THE SYSTEM TURN: one brief as it is, or the whole room. The org's row
|
|
412
|
+
// speaks for an agent it holds — the cloud keeps a core member's row in step
|
|
413
|
+
// with hanzoai/personas, and an agent the org made under the same name keeps
|
|
414
|
+
// its own brief — and a core member the org holds no row for speaks as
|
|
415
|
+
// hanzoai/personas writes them (`speaksOf`), so a visitor pressing Feynman
|
|
416
|
+
// gets Feynman rather than the house model.
|
|
417
|
+
const brief = useMemo(() => roomBrief(room.map((name) => ({
|
|
418
|
+
name,
|
|
419
|
+
says: (name === withWhom ? persona?.instructions?.trim() : briefs[name]) || speaksOf(name) || null,
|
|
420
|
+
}))), [room, withWhom, persona?.instructions, briefs]);
|
|
421
|
+
// AN AGENT BRINGS ITS OWN MODEL. It is a field on the row, so choosing one
|
|
422
|
+
// for them would be answering a question they have already answered. The
|
|
423
|
+
// default for plain chats lives in Settings, so this room has no second model
|
|
424
|
+
// control that can drift from it.
|
|
425
|
+
//
|
|
426
|
+
// NAMED, THEN CHECKED AGAINST WHAT THE GATEWAY SERVES. `enso` is the house
|
|
427
|
+
// router and the right preference, but a deployment carrying a different
|
|
428
|
+
// catalogue does not answer to it — and an unserved name reaches the agents
|
|
429
|
+
// round as a completion for a model nobody has, which the round returns as a
|
|
430
|
+
// bare "not found", after it has already recorded the question. `served`
|
|
431
|
+
// keeps the house name where it is served and otherwise falls to the first
|
|
432
|
+
// model that is, so a room on any catalogue can be spoken to.
|
|
433
|
+
const { models } = useModels();
|
|
434
|
+
// HANZO MODELS ALONE are offered here, to everyone: an Enso or Zen id
|
|
435
|
+
// (`sku`). A preference kept from before for any other model is not one this
|
|
436
|
+
// room offers, so the house router is asked for instead, and `served` keeps
|
|
437
|
+
// to the house models wherever the catalog carries one. An agent's own model
|
|
438
|
+
// is sent as it is named, never swapped for another the catalog lists; the
|
|
439
|
+
// gateway says which model answered (`servedBy`).
|
|
440
|
+
const offered = useMemo(() => models.filter((m) => sku(m.id)), [models]);
|
|
441
|
+
const wanted = sku(preferred) ? preferred : ENSO;
|
|
442
|
+
const model = persona?.model || served(models, anonymous ? FREE : wanted);
|
|
443
|
+
// WHAT THE PLAN HAS LEFT, as shares of the session, the day and the month
|
|
444
|
+
// (lib/hanzo/limits.ts). Read again whenever a send settles, below.
|
|
445
|
+
const { limits, reload: reread } = useLimits(!anonymous, org());
|
|
446
|
+
const held = Boolean(limits?.plan);
|
|
447
|
+
// A MODEL THE FREE PLAN DOES NOT INCLUDE IS ASKED FOR, NOT CHOSEN. On Free,
|
|
448
|
+
// picking a model the plan's row does not run (`onFree`) leaves the choice
|
|
449
|
+
// where it was and opens the upgrade, titled with the model; so does a turn
|
|
450
|
+
// the gateway refuses `plan_required`. A plan the limits name is not Free,
|
|
451
|
+
// whatever the tier says, so every Hanzo model is simply chosen on it.
|
|
452
|
+
// `isFree` still drives the free-lane notice.
|
|
453
|
+
const free = useFree(!anonymous) && !held;
|
|
454
|
+
const [ask, setAsk] = useState(null);
|
|
455
|
+
const choose = (id) => (free && !onFree(id) ? setAsk({ model: nameOf(models, id) }) : setPreferred(id));
|
|
456
|
+
// KEEPING THE CONVERSATION is the reader's account, not this pane's choice:
|
|
457
|
+
// the store is per-org, so a signed-in reader has somewhere for it to go and
|
|
458
|
+
// an anonymous one does not. `thread` names the conversation being continued
|
|
459
|
+
// — the Recent they opened — so a reply lands in it rather than beside it.
|
|
460
|
+
const chat = useChat({
|
|
461
|
+
model,
|
|
462
|
+
keep: !anonymous,
|
|
463
|
+
// THE PILL THAT WAS CUT READ A SETTING NOTHING SET, and that was the right
|
|
464
|
+
// reason to cut it. This is the other half: `params` is forwarded to the
|
|
465
|
+
// completion, so the effort chosen on the bar rides the request. An
|
|
466
|
+
// upstream that does not read `reasoning_effort` answers as it would have,
|
|
467
|
+
// the way it does for `temperature`.
|
|
468
|
+
params: { reasoning_effort: effort },
|
|
469
|
+
...(open ? { thread: open } : {}),
|
|
470
|
+
onError: (e) => {
|
|
471
|
+
const needs = planRequired(e);
|
|
472
|
+
if (needs && free)
|
|
473
|
+
setAsk({ ...needs, model: nameOf(models, model) });
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
const { send, set, error, thread: kept } = chat;
|
|
477
|
+
// Threads THIS pane opened by writing them. Their transcript is already on
|
|
478
|
+
// the screen — it is what was just said — so the restore below has nothing to
|
|
479
|
+
// fetch for one, and fetching anyway would race a live conversation to
|
|
480
|
+
// replace it with the server's copy mid-sentence.
|
|
481
|
+
const written = useRef(new Set());
|
|
482
|
+
// The room each thread last recorded, by thread, as the names joined.
|
|
483
|
+
const recorded = useRef(new Map());
|
|
484
|
+
// A conversation that has just been written down becomes the OPEN one, so the
|
|
485
|
+
// rail marks the room the reader is standing in. Only on the mint, which is
|
|
486
|
+
// `kept` changing: when the reader opens another Recent, `open` moves first and
|
|
487
|
+
// `kept` follows it a render later (the hook takes `thread` in an effect), and
|
|
488
|
+
// reopening the stale `kept` then sent the two chasing each other until React
|
|
489
|
+
// stopped the page (#185).
|
|
490
|
+
const minted = useRef(kept);
|
|
491
|
+
useEffect(() => {
|
|
492
|
+
if (kept === minted.current)
|
|
493
|
+
return;
|
|
494
|
+
minted.current = kept;
|
|
495
|
+
if (kept && kept !== open) {
|
|
496
|
+
written.current.add(kept);
|
|
497
|
+
reopen(kept);
|
|
498
|
+
}
|
|
499
|
+
}, [kept, open, reopen]);
|
|
500
|
+
// The thread's turns, replaced wholesale or through an updater. `set` is the
|
|
501
|
+
// hook's own state setter, so it takes either.
|
|
502
|
+
const apply = useRef(set);
|
|
503
|
+
apply.current = set;
|
|
504
|
+
// THE ROOM'S SYSTEM TURN: who is in the conversation (`roster`), then how each
|
|
505
|
+
// of them talks. It is the first turn every request carries and it follows the room, so
|
|
506
|
+
// pressing a face adds them to the conversation that is open rather than
|
|
507
|
+
// starting another; the turns already said stay where they are.
|
|
508
|
+
const turn = useMemo(() => roomTurn(room, brief), [room, brief]);
|
|
509
|
+
const turnNow = useRef(turn);
|
|
510
|
+
turnNow.current = turn;
|
|
511
|
+
const roomNow = useRef(room);
|
|
512
|
+
roomNow.current = room;
|
|
513
|
+
useEffect(() => {
|
|
514
|
+
apply.current((held) => seated(held, turn));
|
|
515
|
+
}, [turn]);
|
|
516
|
+
// A NEW CONVERSATION holds the room's turn and nothing else: on arrival, on
|
|
517
|
+
// "New chat" (`emptied` counts the presses, so a press on an empty room still
|
|
518
|
+
// lands), and when the open thread is left for none.
|
|
519
|
+
useEffect(() => {
|
|
520
|
+
if (open)
|
|
521
|
+
return;
|
|
522
|
+
apply.current(seated([], turnNow.current));
|
|
523
|
+
}, [open, emptied]);
|
|
524
|
+
// A RECENT THAT OPENS. `threads.get` answers null for an id this caller may
|
|
525
|
+
// not read — the route replies 200 with no messages rather than 404 — so an
|
|
526
|
+
// empty transcript is read as absent instead of drawn as a real but blank
|
|
527
|
+
// conversation. The last roster turn it holds names its room, which is seated
|
|
528
|
+
// as it arrives; a thread that never recorded one has nobody in it.
|
|
529
|
+
//
|
|
530
|
+
// Its own effect, apart from the room's turn: a character arriving while the
|
|
531
|
+
// read is out must not abort it.
|
|
532
|
+
useEffect(() => {
|
|
533
|
+
if (!open)
|
|
534
|
+
return;
|
|
535
|
+
// Our own. The turns are on the screen already.
|
|
536
|
+
if (written.current.has(open))
|
|
537
|
+
return;
|
|
538
|
+
const stop = new AbortController();
|
|
539
|
+
void ai.threads
|
|
540
|
+
.get(open, { signal: stop.signal })
|
|
541
|
+
.then((found) => {
|
|
542
|
+
const turns = (found?.messages ?? []).map((m, i) => ({
|
|
543
|
+
// A store row always has an id; a turn without one is keyed by its place.
|
|
544
|
+
id: m.id || `${open}#${i}`,
|
|
545
|
+
role: m.role,
|
|
546
|
+
content: m.content,
|
|
547
|
+
}));
|
|
548
|
+
let names = [];
|
|
549
|
+
for (const one of turns) {
|
|
550
|
+
const held = one.role === "system" ? rosterOf(one.content) : null;
|
|
551
|
+
if (held)
|
|
552
|
+
names = held;
|
|
553
|
+
}
|
|
554
|
+
recorded.current.set(open, names.join("\n"));
|
|
555
|
+
// The room already seated keeps its briefs; another is seated by name
|
|
556
|
+
// and its briefs follow as they are read.
|
|
557
|
+
const same = names.join("\n") === roomNow.current.join("\n");
|
|
558
|
+
seat(names);
|
|
559
|
+
apply.current(seated(turns.filter((one) => !(one.role === "system" && rosterOf(one.content))), same ? turnNow.current : roomTurn(names, null)));
|
|
560
|
+
})
|
|
561
|
+
.catch(() => apply.current(seated([], turnNow.current)));
|
|
562
|
+
return () => stop.abort();
|
|
563
|
+
}, [ai, open, seat]);
|
|
564
|
+
// WHO IS IN THE CONVERSATION IS KEPT WITH IT. A thread records its room as a
|
|
565
|
+
// roster turn when it is first written down with somebody in it, and again
|
|
566
|
+
// whenever the room changes; reopening it — after a reload, on another
|
|
567
|
+
// device — seats the same faces. The briefs are not recorded: they are read
|
|
568
|
+
// fresh from the agents each time the room is seated.
|
|
569
|
+
useEffect(() => {
|
|
570
|
+
if (!open || anonymous)
|
|
571
|
+
return;
|
|
572
|
+
const now = room.join("\n");
|
|
573
|
+
const was = recorded.current.get(open);
|
|
574
|
+
if (was === now)
|
|
575
|
+
return;
|
|
576
|
+
// A thread read back from the store is seated by the read above; until it
|
|
577
|
+
// lands there is nothing to compare against.
|
|
578
|
+
if (was === undefined && !written.current.has(open))
|
|
579
|
+
return;
|
|
580
|
+
recorded.current.set(open, now);
|
|
581
|
+
if (was === undefined && room.length === 0)
|
|
582
|
+
return;
|
|
583
|
+
void ai.threads.record([{ role: "system", content: rosterLine(room) }], open).catch(() => {
|
|
584
|
+
recorded.current.delete(open);
|
|
585
|
+
});
|
|
586
|
+
}, [ai, open, room, anonymous]);
|
|
587
|
+
// `send` is read through a ref and NEVER through a dependency array. Its
|
|
588
|
+
// identity changes on every `setMessages` — `useChat` closes over `messages` —
|
|
589
|
+
// so an effect depending on it re-runs after each streamed token.
|
|
590
|
+
const latest = useRef(send);
|
|
591
|
+
latest.current = send;
|
|
592
|
+
// THE COMPOSER'S DRAFT IS HELD HERE, so an arriving question can be typed into
|
|
593
|
+
// it. `Chat` owns a draft of its own and spreads `composer` after it, so
|
|
594
|
+
// passing value/onChange/onSend takes that ownership over rather than fighting
|
|
595
|
+
// it.
|
|
596
|
+
const [draft, setDraft] = useState("");
|
|
597
|
+
useSeed(initial, setDraft);
|
|
598
|
+
// DICTATION, the same one the site's own composer offers. `speech()` defaults
|
|
599
|
+
// to the Hanzo gateway and `base()` is the one place a host is read from, so
|
|
600
|
+
// pointing a local cloud at one points both. The token is the reader's own —
|
|
601
|
+
// the package never invents a credential — and where a browser has no
|
|
602
|
+
// recogniser `<Voice/>` wears the reason rather than failing silently.
|
|
603
|
+
const { token } = useIamToken();
|
|
604
|
+
const agentVoice = voiceOf(withWhom);
|
|
605
|
+
const ear = useMemo(() => speech({
|
|
606
|
+
...base(),
|
|
607
|
+
...(token ? { token } : {}),
|
|
608
|
+
voice: { name: agentVoice },
|
|
609
|
+
}), [token, agentVoice]);
|
|
610
|
+
const voice = useVoice({
|
|
611
|
+
speech: ear,
|
|
612
|
+
onPartial: setDraft,
|
|
613
|
+
onUtterance: setDraft,
|
|
614
|
+
});
|
|
615
|
+
// When voice dictation is open, automatically speak incoming assistant replies
|
|
616
|
+
const wasStreaming = useRef(false);
|
|
617
|
+
useEffect(() => {
|
|
618
|
+
if (wasStreaming.current && !chat.streaming && voice.open) {
|
|
619
|
+
const msgs = chat.messages;
|
|
620
|
+
const lastMsg = msgs[msgs.length - 1];
|
|
621
|
+
if (lastMsg && lastMsg.role === "assistant" && typeof lastMsg.content === "string") {
|
|
622
|
+
// A room's reply is read part by part, each in its speaker's voice.
|
|
623
|
+
const parts = room.length > 1 ? speakers(lastMsg.content, room) : [{ who: withWhom, text: lastMsg.content }];
|
|
624
|
+
const read = (i) => {
|
|
625
|
+
if (i >= parts.length)
|
|
626
|
+
return;
|
|
627
|
+
speakAgent(parts[i].text, { agent: parts[i].who ?? withWhom ?? undefined, onEnd: () => read(i + 1) });
|
|
628
|
+
};
|
|
629
|
+
read(0);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
wasStreaming.current = chat.streaming;
|
|
633
|
+
}, [chat.streaming, voice.open, chat.messages, withWhom, room]);
|
|
634
|
+
// A SEND THAT WAITS ONE RENDER. `send` closes over the turns it was rendered
|
|
635
|
+
// with, so a send that first tidies the thread is held here and made from the
|
|
636
|
+
// next render, where the tidied turns are the ones it sends.
|
|
637
|
+
const [queued, setQueued] = useState(null);
|
|
638
|
+
useEffect(() => {
|
|
639
|
+
if (queued === null || resolving)
|
|
640
|
+
return;
|
|
641
|
+
setQueued(null);
|
|
642
|
+
void latest.current(queued);
|
|
643
|
+
}, [queued, resolving]);
|
|
644
|
+
const streaming = useRef(false);
|
|
645
|
+
streaming.current = chat.streaming;
|
|
646
|
+
const turns = useRef(chat.messages);
|
|
647
|
+
turns.current = chat.messages;
|
|
648
|
+
/**
|
|
649
|
+
* WHAT EACH UNANSWERED QUESTION WAS TOLD, by the question's own turn id: the
|
|
650
|
+
* refusal it met, or that its answer came back empty, or that it streamed
|
|
651
|
+
* nothing until `STALL`. A question with no entry here and no answer after it
|
|
652
|
+
* — a thread read back from the store — is still said to have gone unanswered.
|
|
653
|
+
*/
|
|
654
|
+
const [failures, setFailures] = useState({});
|
|
655
|
+
const note = useCallback((failed) => {
|
|
656
|
+
const id = [...turns.current].reverse().find((m) => m.role === "user")?.id;
|
|
657
|
+
if (id)
|
|
658
|
+
setFailures((all) => ({ ...all, [id]: failed }));
|
|
659
|
+
}, []);
|
|
660
|
+
useEffect(() => {
|
|
661
|
+
if (error && !chat.streaming)
|
|
662
|
+
note({ error });
|
|
663
|
+
}, [error, chat.streaming, note]);
|
|
664
|
+
// EVERY SEND THAT SETTLES re-reads the plan's limits: a stream that ended, a
|
|
665
|
+
// stop, or a refusal — a 429 for a spent window included, which is read
|
|
666
|
+
// again on the pass that delivers it. An error with no stream before it
|
|
667
|
+
// still counts, so a refusal raised before the first frame is not missed.
|
|
668
|
+
const counting = useRef(false);
|
|
669
|
+
useEffect(() => {
|
|
670
|
+
if (chat.streaming) {
|
|
671
|
+
counting.current = true;
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (!counting.current && !error)
|
|
675
|
+
return;
|
|
676
|
+
counting.current = false;
|
|
677
|
+
reread();
|
|
678
|
+
}, [chat.streaming, error, reread]);
|
|
679
|
+
const wasBusy = useRef(false);
|
|
680
|
+
useEffect(() => {
|
|
681
|
+
const held = turns.current[turns.current.length - 1];
|
|
682
|
+
if (wasBusy.current && !chat.streaming && !error && held && blank(held))
|
|
683
|
+
note({ error: null, empty: true });
|
|
684
|
+
wasBusy.current = chat.streaming;
|
|
685
|
+
}, [chat.streaming, error, note]);
|
|
686
|
+
/**
|
|
687
|
+
* Sends a question. A new question takes any empty answer off the thread
|
|
688
|
+
* first, because an upstream may refuse a request carrying an assistant turn
|
|
689
|
+
* with nothing in it. `again` names an unanswered question to ask once more:
|
|
690
|
+
* it leaves its place in the thread and is asked at the end, so it is asked
|
|
691
|
+
* once and not twice.
|
|
692
|
+
*/
|
|
693
|
+
const speak = useCallback((text, again) => {
|
|
694
|
+
if (!text)
|
|
695
|
+
return;
|
|
696
|
+
// Activation: the first question this browser asks (a retry is not one).
|
|
697
|
+
if (!again)
|
|
698
|
+
first("chat_message");
|
|
699
|
+
if (!streaming.current && (again || resolvingNow.current || turns.current.some(blank))) {
|
|
700
|
+
apply.current((held) => held.filter((one) => one.id !== again && !blank(one)));
|
|
701
|
+
if (again)
|
|
702
|
+
setFailures((all) => {
|
|
703
|
+
const { [again]: _gone, ...rest } = all;
|
|
704
|
+
return rest;
|
|
705
|
+
});
|
|
706
|
+
setQueued(text);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
void latest.current(text);
|
|
710
|
+
}, []);
|
|
711
|
+
// A TURN THAT NEVER SPEAKS IS STOPPED. A stream that stays open with nothing
|
|
712
|
+
// in it would leave a Stop button and no answer for as long as the upstream
|
|
713
|
+
// holds the connection; after `STALL` it is ended and its question is said to
|
|
714
|
+
// have gone unanswered, with Try again.
|
|
715
|
+
useEffect(() => {
|
|
716
|
+
if (!chat.streaming)
|
|
717
|
+
return;
|
|
718
|
+
const clock = window.setTimeout(() => {
|
|
719
|
+
const held = turns.current[turns.current.length - 1];
|
|
720
|
+
if (held && blank(held)) {
|
|
721
|
+
note({ error: null, stalled: true });
|
|
722
|
+
chat.stop();
|
|
723
|
+
}
|
|
724
|
+
}, STALL);
|
|
725
|
+
return () => window.clearTimeout(clock);
|
|
726
|
+
// Armed once per turn; `chat.stop` is the hook's stable callback.
|
|
727
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
728
|
+
}, [chat.streaming]);
|
|
729
|
+
// WHICH MODEL ANSWERED each reply, by the reply's turn id, as the gateway named
|
|
730
|
+
// it while the reply streamed (lib/served.ts).
|
|
731
|
+
const [servedBy, setServedBy] = useState({});
|
|
732
|
+
useEffect(() => onServed((heard) => {
|
|
733
|
+
const answer = [...turns.current].reverse().find((m) => m.role === "assistant");
|
|
734
|
+
if (answer && streaming.current)
|
|
735
|
+
setServedBy((all) => ({ ...all, [answer.id]: heard }));
|
|
736
|
+
}), []);
|
|
737
|
+
const byline = (id) => {
|
|
738
|
+
const heard = servedBy[id];
|
|
739
|
+
// A router named back as itself says nothing about who answered.
|
|
740
|
+
if (!heard || (heard.served === heard.asked && ROUTERS.has(heard.asked)))
|
|
741
|
+
return null;
|
|
742
|
+
const fell = heard.asked && heard.asked !== heard.served && !ROUTERS.has(heard.asked);
|
|
743
|
+
return (_jsxs(Text, { fontSize: "$1", color: "$soft", children: ["answered by ", heard.served, fell ? ` · ${heard.asked} was unavailable` : ""] }));
|
|
744
|
+
};
|
|
745
|
+
// WHAT WAS SAID, which is not everything the thread holds. The persona rides
|
|
746
|
+
// as a system turn so the wire carries it; drawing it would print a character
|
|
747
|
+
// brief in the transcript as though somebody had typed it, and would make a
|
|
748
|
+
// room nobody has spoken in look like a conversation already under way.
|
|
749
|
+
const said = useMemo(() => chat.messages.filter((m) => m.role !== "system"), [chat.messages]);
|
|
750
|
+
// THE LIKENESS, WHERE ONE WAS DRAWN FOR THIS CHARACTER. `cast.tsx` is the one
|
|
751
|
+
// table of who looks like what, and it says a name it does not hold "is not a
|
|
752
|
+
// problem" — that name wears initials, never another character's face.
|
|
753
|
+
// WHAT THE AGENT CARRIES, THEN WHAT WE CAST. The cast answers for a character
|
|
754
|
+
// it has a part for; an agent named after its job — "Hanzo Researcher" — has
|
|
755
|
+
// no part, so the hero drew nothing while the column beside it drew the
|
|
756
|
+
// record's own picture. Its own answer comes first, which is the order
|
|
757
|
+
// `Face` already states for every other surface.
|
|
758
|
+
const portrait = mate?.avatar ?? persona?.avatar ?? roleOf(withWhom)?.portrait;
|
|
759
|
+
/** Whether the field has focus, so the FRAME can wear the ring the field is
|
|
760
|
+
* no longer allowed to draw. `:focus-within` would say this in CSS; the
|
|
761
|
+
* stylesheet that would hold it cannot reach past gui's own rule, so the
|
|
762
|
+
* state is read where the event is. */
|
|
763
|
+
const guarded = useCallback((text) => {
|
|
764
|
+
const s = store();
|
|
765
|
+
// FAILS CLOSED. This read `s && !hasConsent(s)`, so a browser that gives
|
|
766
|
+
// us no store answered "no consent needed" and sent a data-shared turn
|
|
767
|
+
// having asked nobody. No store means no record, and no record means ask.
|
|
768
|
+
if (isFree(model) && (!s || !hasConsent(s))) {
|
|
769
|
+
setAsking(text);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
speak(text);
|
|
773
|
+
}, [model, speak]);
|
|
774
|
+
/*
|
|
775
|
+
WHAT AN UNANSWERED QUESTION LOOKS LIKE: a turn of its own directly under it,
|
|
776
|
+
naming who was asked, saying what went wrong in the reader's words, and
|
|
777
|
+
offering Try again. Every question ends in an answer or in one of these,
|
|
778
|
+
never in nothing: the thread drawn is `shown`, which puts one after every
|
|
779
|
+
question with no answer after it.
|
|
780
|
+
|
|
781
|
+
The refusals the reader can act on keep the server's sentence and its way
|
|
782
|
+
out — Add credit only for the caller's OWN wallet (`insufficient_balance`),
|
|
783
|
+
Upgrade for a plan limit, Sign in for a visitor. A model provider that did
|
|
784
|
+
not answer (`providers_exhausted`, or any 5xx on the turn) is ours, and says
|
|
785
|
+
so plainly with Try again; the upstream account named inside it is not the
|
|
786
|
+
reader's to fund.
|
|
787
|
+
*/
|
|
788
|
+
const shown = useMemo(() => {
|
|
789
|
+
const out = [];
|
|
790
|
+
const held = said.filter((one, i) => !blank(one) || (chat.streaming && i === said.length - 1));
|
|
791
|
+
held.forEach((one, i) => {
|
|
792
|
+
out.push(one);
|
|
793
|
+
if (one.role !== "user")
|
|
794
|
+
return;
|
|
795
|
+
const next = held[i + 1];
|
|
796
|
+
if (!next || next.role === "user")
|
|
797
|
+
out.push({ id: `${FAILED}:${one.id}`, role: "assistant", content: "" });
|
|
798
|
+
});
|
|
799
|
+
// A question on its way out has no answer yet and no failure either.
|
|
800
|
+
if (queued !== null && out.length && String(out[out.length - 1].id).startsWith(`${FAILED}:`))
|
|
801
|
+
out.pop();
|
|
802
|
+
return out;
|
|
803
|
+
}, [said, chat.streaming, queued]);
|
|
804
|
+
const failure = (question) => {
|
|
805
|
+
const failed = failures[question.id];
|
|
806
|
+
const text = String(question.content ?? "");
|
|
807
|
+
const whom = addressed(text, room);
|
|
808
|
+
const wrong = failed?.error ?? null;
|
|
809
|
+
const how = wrong ? refusalOf(wrong) : null;
|
|
810
|
+
// A SPENT WINDOW is the plan holder's own limit, said with its reset and,
|
|
811
|
+
// below the top plan, the next plan's checkout. Asking again before the
|
|
812
|
+
// reset meets the same answer, so it offers no Try again.
|
|
813
|
+
const cap = wrong ? capped(wrong) : null;
|
|
814
|
+
const next = cap?.href ? planName(limits?.upgrade) : null;
|
|
815
|
+
const resign = !cap && !!wrong && (refused(wrong) || /audience not allowed|invalid access token/i.test(wrong.message));
|
|
816
|
+
const lift = free ? planRequired(wrong) : null;
|
|
817
|
+
const said = !failed
|
|
818
|
+
? "No answer came back for this question."
|
|
819
|
+
: failed.stalled
|
|
820
|
+
? `${nameOf(models, model)} sent nothing for ${STALL / 60_000} minutes, so the turn was stopped.`
|
|
821
|
+
: failed.empty
|
|
822
|
+
? `${nameOf(models, model)} answered with nothing.`
|
|
823
|
+
: cap
|
|
824
|
+
? `You've used ${cap.limit === "session" ? "this session's" : "today's"} limit.${cap.resets ? ` Resets at ${clock(cap.resets)}.` : ""}`
|
|
825
|
+
: resign
|
|
826
|
+
? anonymous
|
|
827
|
+
? "Sign in to ask this."
|
|
828
|
+
: "Your session needs to be refreshed. Sign in to reconnect."
|
|
829
|
+
: how?.provider
|
|
830
|
+
? `${familyOf(model) || nameOf(models, model)} is unavailable right now.`
|
|
831
|
+
: plainly(wrong, "this conversation");
|
|
832
|
+
return (_jsx(Failure, { onRetry: resign || cap ? undefined : () => speak(text, question.id), children: _jsxs(YStack, { gap: "$2", children: [_jsxs(Text, { fontSize: "$3", color: "$ink", children: [whom.length ? `No answer from ${join(whom)}. ` : "", said] }), how?.provider && wrong ? _jsx(Why, { text: detail(wrong) }) : null, cap?.href || resign || lift || how?.wallet || how?.visitor ? (_jsx(XStack, { gap: "$2", flexWrap: "wrap", children: cap?.href ? (_jsx(Anchor, { href: cap.href, children: _jsx(Button, { size: "sm", variant: "outline", children: next ? `Upgrade to ${next}` : "Upgrade" }) })) : resign ? (_jsx(Button, { size: "sm", onClick: () => {
|
|
833
|
+
if (typeof window !== "undefined") {
|
|
834
|
+
localStorage.removeItem("hanzo_iam_access_token");
|
|
835
|
+
localStorage.removeItem("hanzo_iam_refresh_token");
|
|
836
|
+
}
|
|
837
|
+
enter(LOGIN);
|
|
838
|
+
}, children: "Sign in" })) : lift ? (_jsx(Button, { size: "sm", variant: "outline", onClick: () => setAsk({ ...lift, model: nameOf(models, model) }), children: "Upgrade" })) : how?.visitor ? (_jsx(Button, { size: "sm", variant: "outline", onClick: () => enter(), children: "Sign in to keep going" })) : (_jsx(Anchor, { href: checkoutUrl(), children: _jsx(Button, { size: "sm", variant: "outline", children: "Add credit" }) })) })) : null] }) }));
|
|
839
|
+
};
|
|
840
|
+
// The agreement is asked OVER the room, not instead of it. Returning the
|
|
841
|
+
// consent on its own replaced the whole pane: the question the reader had
|
|
842
|
+
// just typed left the screen, and the first thing a stranger saw of the
|
|
843
|
+
// product was a page about training data. The room stays; the sheet sits on
|
|
844
|
+
// top of it, and the question is still there behind it.
|
|
845
|
+
const consent = asking === null ? null : (_jsx(Consent, { question: asking, onAgree: () => {
|
|
846
|
+
const s = store();
|
|
847
|
+
// Nothing to persist into is not a reason to refuse the turn the
|
|
848
|
+
// reader just agreed to; it means the question is asked again next
|
|
849
|
+
// time, which is the honest cost of a browser that stores nothing.
|
|
850
|
+
if (s)
|
|
851
|
+
grantConsent(s);
|
|
852
|
+
const held = asking;
|
|
853
|
+
setAsking(null);
|
|
854
|
+
if (held)
|
|
855
|
+
speak(held);
|
|
856
|
+
}, onCancel: () => {
|
|
857
|
+
setAsking(null);
|
|
858
|
+
} }));
|
|
859
|
+
return (_jsxs(YStack, { flex: 1, minH: 0, width: "100%", position: "relative", children: [_jsxs(XStack, { height: 48, items: "center", justify: "space-between", px: "$3", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsxs(XStack, { items: "center", gap: "$2", minW: 0, children: [_jsx(Box, { render: "button", onClick: () => open && star(open), "aria-label": open && starred.has(open) ? "Unstar this conversation" : "Star this conversation", "aria-pressed": !!open && starred.has(open), p: "$1.5", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(Star, { size: 15, "aria-hidden": true, color: open && starred.has(open) ? "var(--foreground)" : "var(--muted-foreground)", fill: open && starred.has(open) ? "currentColor" : "none" }) }), room.length ? (_jsx(XStack, { role: "list", "aria-label": "Agents in this chat", items: "center", gap: "$1.5", minW: 0, overflowX: "auto", "$platform-web": { scrollbarWidth: "none" }, children: room.map((name) => (_jsxs(XStack, { role: "listitem", items: "center", gap: "$1.5", pl: "$1", pr: "$0.5", py: "$0.5", rounded: 999, borderWidth: 1, borderColor: "$borderColor", shrink: 0, children: [_jsx(Face, { src: faces[name]?.avatar ?? (name === withWhom ? portrait : undefined), emoji: faces[name]?.emoji ?? (name === withWhom ? persona?.emoji : undefined), name: name, size: 22 }), _jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$color", numberOfLines: 1, display: "none", $sm: { display: "flex" }, children: name }), _jsx(Box, { render: "button", onClick: () => openAgent(name), "aria-label": `Remove ${name} from this chat`, title: `Remove ${name}`, width: 24, height: 24, rounded: 999, items: "center", justify: "center", hoverStyle: { bg: "$hover" }, children: _jsx(X, { size: 13, "aria-hidden": true, color: "var(--soft, var(--muted-foreground))" }) })] }, name))) })) : null] }), _jsxs(XStack, { items: "center", gap: "$1", children: [_jsx(Box, { render: "button", onClick: () => {
|
|
860
|
+
if (aside === null) {
|
|
861
|
+
showAside("tasks");
|
|
862
|
+
if (!pinned)
|
|
863
|
+
flipPin();
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
flipPin();
|
|
867
|
+
}, "aria-label": "Toggle pinned summary", "aria-pressed": aside !== null && pinned,
|
|
868
|
+
// NOT ON A PHONE, because there is nothing for it to show there.
|
|
869
|
+
// The frame draws the column only above `$lg` — it spends the whole
|
|
870
|
+
// width on the room below that (`Shell.tsx`) — so this control
|
|
871
|
+
// would press, change the stored pin, and move nothing on screen.
|
|
872
|
+
// A button whose effect is invisible is a button that does nothing.
|
|
873
|
+
// Same breakpoint as the column, so the two cannot drift apart.
|
|
874
|
+
display: "none", $lg: { display: "flex" }, p: "$2", rounded: "$2", hoverStyle: { bg: "$hover" }, children: _jsx(PanelRight, { size: 15, color: "var(--soft, var(--muted-foreground))" }) }), open ? _jsx(Share, { thread: open }) : null] })] }), _jsxs(Beside, { children: [_jsx(RightPane, { said: said, onClose: () => showAside(null) }), _jsx(Work, { persona: persona ?? null, room: room, model: model, messages: said })] }), isFree(model) ? (_jsxs(Text, { fontSize: "$2", color: "$soft", text: "center", maxW: MEASURE, mx: "auto", px: "$4", py: "$2", children: [freeCopy.notice, " ", _jsx(Anchor, { href: site("/legal/privacy"), children: freeCopy.noticeCta })] })) : null, _jsx(ChatSurface, { ...chat, messages: shown, send: guarded,
|
|
875
|
+
// AN EMPTY ROOM CENTRES ITS COMPOSER. While nothing has been said the
|
|
876
|
+
// opening (the heading and the starters) and the composer sit as one
|
|
877
|
+
// group in the middle of the height, with or without the plan bars in
|
|
878
|
+
// the composer's foot; the moment a turn exists the transcript takes the
|
|
879
|
+
// height and the composer docks under it.
|
|
880
|
+
center: true,
|
|
881
|
+
// AS IT WAS WRITTEN. The package renders a turn's words plainly and
|
|
882
|
+
// says so — it ships no markdown pipeline because the plugin set is the
|
|
883
|
+
// surface's to choose — so an answer arrived here reading "```ts" with
|
|
884
|
+
// the backticks on screen. `Prose` is the site's one renderer, reading
|
|
885
|
+
// with the same `marked` the legal pages parse with and drawing every
|
|
886
|
+
// token in gui elements, and the shared view draws through it too.
|
|
887
|
+
body: (turn) => {
|
|
888
|
+
if (String(turn.id).startsWith(`${FAILED}:`)) {
|
|
889
|
+
const question = shown.find((one) => one.id === String(turn.id).slice(FAILED.length + 1));
|
|
890
|
+
return question ? failure(question) : null;
|
|
891
|
+
}
|
|
892
|
+
const said = typeof turn.content === "string" ? turn.content : "";
|
|
893
|
+
// WHO IS ANSWERING, while the answer has no words yet: the agents the
|
|
894
|
+
// question addresses, or the whole room when it names nobody.
|
|
895
|
+
if (turn.role === "assistant" && !said && chat.streaming && room.length) {
|
|
896
|
+
const q = [...shown].reverse().find((one) => one.role === "user");
|
|
897
|
+
const whom = addressed(String(q?.content ?? ""), room);
|
|
898
|
+
return (_jsxs(Text, { fontSize: "$2", color: "$soft", children: [join(whom), " ", whom.length > 1 ? "are" : "is", " answering\u2026"] }));
|
|
899
|
+
}
|
|
900
|
+
// A ROOM'S ANSWER IS SEVERAL PEOPLE'S. Each opens a line with their
|
|
901
|
+
// name, as the brief asks, so each is drawn under their own face and
|
|
902
|
+
// read in their own voice. A reply that names nobody is drawn whole.
|
|
903
|
+
const parts = turn.role === "assistant" ? speakers(said, known) : null;
|
|
904
|
+
if (parts && parts.some((part) => part.who)) {
|
|
905
|
+
// Asked and silent: an agent the question addressed who has no part
|
|
906
|
+
// in this reply.
|
|
907
|
+
const at = shown.findIndex((one) => one.id === turn.id);
|
|
908
|
+
const q = shown.slice(0, at).reverse().find((one) => one.role === "user");
|
|
909
|
+
const silent = room.length > 1 && !(chat.streaming && at === shown.length - 1)
|
|
910
|
+
? addressed(String(q?.content ?? ""), room).filter((name) => !parts.some((part) => part.who?.toLowerCase() === name.toLowerCase()))
|
|
911
|
+
: [];
|
|
912
|
+
return (_jsxs(YStack, { gap: "$3", children: [parts.map((part, i) => (_jsxs(YStack, { gap: "$1", children: [part.who ? (_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(Face, { src: faces[part.who]?.avatar, emoji: faces[part.who]?.emoji, name: part.who, size: 20 }), _jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$ink", children: part.who })] })) : null, _jsx(Prose, { text: part.text }), part.text ? (_jsxs(XStack, { ml: "$-1", items: "center", gap: "$2", children: [_jsx(Take, { text: part.text, says: "this answer" }), _jsx(SpeakButton, { text: part.text, agentName: part.who ?? withWhom ?? undefined })] })) : null] }, i))), silent.length ? (_jsxs(Text, { fontSize: "$2", color: "$soft", children: ["No answer from ", join(silent), " in this reply."] })) : null, byline(turn.id)] }));
|
|
913
|
+
}
|
|
914
|
+
return (_jsxs(YStack, { gap: "$1", children: [_jsx(Prose, { text: said }), turn.role === "assistant" && said ? (_jsxs(XStack, { ml: "$-1", items: "center", gap: "$2", children: [_jsx(Take, { text: said, says: "this answer" }), _jsx(SpeakButton, { text: said, agentName: withWhom })] })) : null, turn.role === "assistant" && said ? byline(turn.id) : null] }));
|
|
915
|
+
},
|
|
916
|
+
// The page's ONE heading, and it belongs to the empty transcript rather
|
|
917
|
+
// than to a signed-out branch — the branch is gone, and the heading went
|
|
918
|
+
// with it when it did. `Ask` passes its own, because the page embedding
|
|
919
|
+
// it already has an `<h1>` and a second is the defect that took the
|
|
920
|
+
// landings off /chat and /dev.
|
|
921
|
+
empty: heading === null ? undefined : (_jsxs(YStack, { "data-slot": "opening", items: "center", width: "100%", py: "$6", children: [room.length > 1 ? (_jsx(XStack, { items: "center", justify: "center", mb: "$4", children: room.map((name, i) => (_jsx(Box, { ml: i ? -16 : 0, rounded: 40, borderWidth: 2, borderColor: "$background", z: room.length - i, children: _jsx(Face, { src: faces[name]?.avatar, emoji: faces[name]?.emoji, name: name, size: 72 }) }, name))) })) : portrait ? (_jsx(Box, { width: 160, height: 160, rounded: 24, overflow: "hidden", borderWidth: 2, borderColor: "var(--white-15)", boxShadow: "0 10px 24px color-mix(in srgb, var(--pure-black) 70%, transparent)", mb: "$4", bg: "var(--neutral-900)", children: _jsx(View, { render: _jsx("img", { src: portrait, alt: withWhom }), width: "100%", height: "100%", objectFit: "cover" }) })) : null, room.length === 1 && !portrait ? (_jsx(Box, { mb: "$3", children: _jsx(Face, { emoji: mate?.emoji ?? persona?.emoji, name: withWhom, size: 72 }) })) : null, _jsx(Text, { render: "h1", fontSize: "$7", lineHeight: "$7", fontWeight: "700", color: "$ink", text: "center", mb: "$1", children: join(room) || heading || "What can I help with?" }), room.length === 1 && (mate?.description ?? persona?.description) ? (_jsx(Text, { fontSize: "$3", color: "$soft", text: "center", maxW: 500, mb: "$4", lineHeight: "$4", children: mate?.description ?? persona?.description })) : null, _jsx(XStack, { flexWrap: "wrap", justify: "center", gap: "$2", maxW: 560, px: "$3", pb: "$4", children: STARTERS.map((one) => (_jsx(Box, { render: "button", onClick: () => {
|
|
922
|
+
setDraft(`${one} `);
|
|
923
|
+
}, px: "$3", py: "$2", rounded: "$4", borderWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: "$raised" }, children: _jsx(Text, { fontSize: "$2", color: "$soft", children: one }) }, one))) })] })),
|
|
924
|
+
// The transcript reads at the same measure. `Thread` defaults to 768
|
|
925
|
+
// itself; handing it the column here is what lets Width reach it.
|
|
926
|
+
column: { maxW: MEASURE }, composer: {
|
|
927
|
+
value: draft,
|
|
928
|
+
onChange: setDraft,
|
|
929
|
+
onSend: () => {
|
|
930
|
+
const text = draft.trim();
|
|
931
|
+
if (!text)
|
|
932
|
+
return;
|
|
933
|
+
setDraft("");
|
|
934
|
+
guarded(text);
|
|
935
|
+
},
|
|
936
|
+
// A CHARACTER IS WHO YOU ARE WRITING TO, so the field says so — and
|
|
937
|
+
// `label` keeps the accessible name constant while it does, which is
|
|
938
|
+
// the reason the component ships both: a placeholder that changes with
|
|
939
|
+
// the room would otherwise rename the control every time you switch.
|
|
940
|
+
placeholder: room.length ? `Message ${join(room)}` : placeholder,
|
|
941
|
+
label: ASK,
|
|
942
|
+
// CENTRED WHILE NOTHING HAS BEEN SAID, docked once something has —
|
|
943
|
+
// `center` on the surface, which spaces the pair from outside it,
|
|
944
|
+
// because with a foot the composer is a shell around this frame and
|
|
945
|
+
// a margin given here would land inside the shell.
|
|
946
|
+
// THE SAME MEASURE THE REST OF THIS FILE READS AT. The notice line and
|
|
947
|
+
// the error block are centred. A line of prose has a
|
|
948
|
+
// comfortable length and a field people write prose into has the same
|
|
949
|
+
// one.
|
|
950
|
+
width: "calc(100% - 32px)",
|
|
951
|
+
maxW: `calc(${MEASURE} - 3rem)`,
|
|
952
|
+
mx: "auto",
|
|
953
|
+
...(framed ? pane() : null),
|
|
954
|
+
// THE COMPONENT'S OWN SHAPE, and nothing here reaching into it.
|
|
955
|
+
//
|
|
956
|
+
// This carried about twenty style props that rebuilt it into a single
|
|
957
|
+
// capsule: `flexDirection: "row"` above all, which lays the footer row
|
|
958
|
+
// BESIDE a `flex: 1` field instead of under it. The footer row has no
|
|
959
|
+
// width of its own in that arrangement, so send left the viewport at
|
|
960
|
+
// every size but the widest — measured at 1280 it ended at 1315, at
|
|
961
|
+
// 834 at 902, at 390 at 466, and on a phone the field stood 200px tall
|
|
962
|
+
// where a line is 24. Each override was answering damage from the one
|
|
963
|
+
// before it.
|
|
964
|
+
//
|
|
965
|
+
// Composer is a field over a footer row. CHOOSING a model is a
|
|
966
|
+
// preference and not part of every message, so Settings owns the
|
|
967
|
+
// choice — but the row still NAMES what is about to answer, because
|
|
968
|
+
// `served()` may not send what was asked for.
|
|
969
|
+
//
|
|
970
|
+
// That fallback is written against this: a preference the gateway
|
|
971
|
+
// does not carry gives way to a model it does, "because a chat that
|
|
972
|
+
// works on a model the reader can see beats a chat that refuses on a
|
|
973
|
+
// name nobody serves". Measured on production, neither `enso` nor
|
|
974
|
+
// `free` is in the catalogue — every turn lands on the first served
|
|
975
|
+
// model — so without this the substitution happens in silence and the
|
|
976
|
+
// sentence justifying it is not true.
|
|
977
|
+
//
|
|
978
|
+
// Pressing it opens the pane that owns the choice, which is the one
|
|
979
|
+
// place to change it.
|
|
980
|
+
// Everything that is not the field, at the end of the row: Enso, the
|
|
981
|
+
// mic, send. Send is drawn here because this slot replaces the
|
|
982
|
+
// package's button and `streaming` still has to become stop; it keeps
|
|
983
|
+
// `data-slot="composer-send"` so a driver finds it, and it is the same
|
|
984
|
+
// `Control` circle as the mic, filled.
|
|
985
|
+
send: (_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(Enso, { effort: effort, onEffort: setEffort, model: wanted, onModel: choose, models: offered }), _jsx(Control, { asChild: true, size: ROUND, children: _jsx(Voice, { voice: voice, children: (state) => (_jsx(Mic, { size: 15, fill: state === "idle" ? "none" : "currentColor" })) }) }), _jsx(Control, { type: "button", size: ROUND, fill: true, "data-slot": "composer-send", "aria-label": chat.streaming ? "Stop" : "Send", opacity: chat.streaming || draft.trim() ? 1 : 0.4, onClick: () => {
|
|
986
|
+
if (chat.streaming)
|
|
987
|
+
return chat.stop();
|
|
988
|
+
const text = draft.trim();
|
|
989
|
+
if (!text)
|
|
990
|
+
return;
|
|
991
|
+
setDraft("");
|
|
992
|
+
guarded(text);
|
|
993
|
+
}, children: chat.streaming ? (_jsx(Square, { size: 13, fill: "currentColor" })) : (_jsx(ArrowUp, { size: 15, strokeWidth: 2.5 })) })] })),
|
|
994
|
+
// WHAT THE PLAN HAS LEFT, under the frame and at its measure, for a
|
|
995
|
+
// plan holder only: Free keeps the free lane's own notice.
|
|
996
|
+
foot: held && limits && (limits.session || limits.day || limits.month) ? (_jsx(YStack, { width: "calc(100% - 32px)", maxW: `calc(${MEASURE} - 3rem)`, mx: "auto", children: _jsx(Meters, { limits: limits }) })) : undefined,
|
|
997
|
+
} }), _jsx(Crew, {}), isFree(model) ? (_jsx(XStack, { items: "center", justify: "center", py: "$1", px: "$3", borderTopWidth: 1, borderColor: "$borderColor", bg: "$background", children: _jsxs(Text, { fontSize: "$1", color: "$soft", children: ["Free chat is data-shared to train models. ", _jsx(Anchor, { href: site("/privacy"), fontSize: "$1", color: "$ink", children: "Privacy" })] }) })) : null, consent, _jsx(Upgrade, { ask: ask, onClose: () => setAsk(null) })] }));
|
|
998
|
+
}
|
|
999
|
+
/** The agreement free asks for, in @hanzo/ai's words so every surface says one thing. */
|
|
1000
|
+
function Consent({ question, onAgree, onCancel, }) {
|
|
1001
|
+
return (_jsx(YStack, { position: "absolute", t: 0, l: 0, r: 0, b: 0, items: "center", justify: "center", p: "$4", z: 40, bg: SCRIM, children: _jsxs(YStack, { width: "100%", maxW: 512, px: "$5", py: "$5", gap: "$4", rounded: "$4", borderWidth: 1, borderColor: "$borderColor", bg: "$background", children: [_jsx(Text, { render: "h2", fontSize: "$6", fontWeight: "500", color: "$ink", children: freeCopy.consentTitle }), question ? (_jsxs(Text, { fontSize: "$3", color: "$soft", numberOfLines: 2, children: ["Your question is waiting: \u201C", question, "\u201D"] })) : null, _jsx(Text, { fontSize: "$4", color: "$soft", children: freeCopy.consentBody }), _jsx(YStack, { gap: "$2", children: freeCopy.consentPoints.map((point) => (_jsx(Text, { fontSize: "$3", color: "$soft", children: point }, point))) }), _jsxs(XStack, { gap: "$3", flexWrap: "wrap", children: [_jsx(Button, { onClick: onAgree, children: freeCopy.consentAgreeCta }), _jsx(Button, { variant: "outline", onClick: onCancel, children: freeCopy.consentCancelCta })] }), _jsxs(Text, { fontSize: "$2", color: "$soft", children: [freeCopy.termsText, " ", _jsx(Anchor, { href: site("/legal/terms"), children: "Terms" }), " · ", _jsx(Anchor, { href: site("/legal/privacy"), children: "Privacy" })] })] }) }));
|
|
1002
|
+
}
|
|
1003
|
+
function Work({ persona, room, model, messages, }) {
|
|
1004
|
+
const done = useMemo(() => {
|
|
1005
|
+
// A result arrives as its own turn, keyed back to the call that asked for
|
|
1006
|
+
// it — so the pairing is a lookup and not an assumption about order.
|
|
1007
|
+
const back = new Map();
|
|
1008
|
+
for (const m of messages) {
|
|
1009
|
+
if (m.role === "tool" && m.tool_call_id) {
|
|
1010
|
+
back.set(m.tool_call_id, typeof m.content === "string" ? m.content : "");
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
return messages
|
|
1014
|
+
.flatMap((m) => m.tool_calls ?? [])
|
|
1015
|
+
.map((call) => ({
|
|
1016
|
+
id: call.id,
|
|
1017
|
+
name: call.function.name,
|
|
1018
|
+
asked: call.function.arguments,
|
|
1019
|
+
got: back.get(call.id) ?? null,
|
|
1020
|
+
}));
|
|
1021
|
+
}, [messages]);
|
|
1022
|
+
/**
|
|
1023
|
+
* WHAT THE CONVERSATION MADE.
|
|
1024
|
+
*
|
|
1025
|
+
* A fenced block in an answer is a thing rather than a sentence — a file, a
|
|
1026
|
+
* command, a page — and read inline it scrolls away with the talk. Here it
|
|
1027
|
+
* keeps a label and a copy control, which is what somebody came to a code
|
|
1028
|
+
* block to do.
|
|
1029
|
+
*
|
|
1030
|
+
* Read from the turns rather than tracked as it arrives: the transcript is
|
|
1031
|
+
* already the record, and a second list of what was made would be a second
|
|
1032
|
+
* thing to keep in step with it.
|
|
1033
|
+
*/
|
|
1034
|
+
const made = useMemo(() => {
|
|
1035
|
+
const out = [];
|
|
1036
|
+
for (const m of messages) {
|
|
1037
|
+
if (m.role !== "assistant")
|
|
1038
|
+
continue;
|
|
1039
|
+
const text = typeof m.content === "string" ? m.content : "";
|
|
1040
|
+
for (const block of text.matchAll(/```([\w.+-]*)\n([\s\S]*?)```/g)) {
|
|
1041
|
+
const code = block[2].replace(/\s+$/, "");
|
|
1042
|
+
if (code)
|
|
1043
|
+
out.push({ id: `${m.id}-${out.length}`, language: block[1] || "text", code });
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
return out;
|
|
1047
|
+
}, [messages]);
|
|
1048
|
+
return (_jsxs(YStack, { p: "$3", gap: "$4", children: [_jsxs(YStack, { gap: "$1", children: [_jsx(Text, { fontSize: "$1", color: "$soft", textTransform: "uppercase", children: "Answering" }), room.length ? (room.map((name) => (_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(Face, { name: name, size: 22 }), _jsx(Text, { fontSize: "$2", color: "$ink", numberOfLines: 1, children: name })] }, name)))) : (_jsx(Text, { fontSize: "$2", color: "$ink", numberOfLines: 1, children: model })), room.length === 1 && persona ? (_jsx(Text, { fontSize: "$1", color: "$soft", numberOfLines: 3, children: persona.description || model })) : null] }), made.length > 0 ? (_jsxs(YStack, { gap: "$2", children: [_jsx(Text, { fontSize: "$1", color: "$soft", textTransform: "uppercase", children: "Artifacts" }), made.map((one) => (_jsx(Code, { language: one.language, value: one.code, children: one.code }, one.id)))] })) : null, _jsxs(YStack, { gap: "$2", children: [_jsx(Text, { fontSize: "$1", color: "$soft", textTransform: "uppercase", children: "Work" }), done.length === 0 ? (_jsx(Text, { fontSize: "$2", color: "$soft", children: "Nothing was looked up for this conversation yet." })) : (done.map((call) => (_jsxs(YStack, { gap: "$1", p: "$2", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(Text, { fontSize: "$2", color: "$ink", children: call.name }), _jsx(Text, { fontSize: "$1", color: "$soft", numberOfLines: 2, children: call.asked }), _jsx(Text, { fontSize: "$1", color: "$soft", numberOfLines: 6, children: call.got ?? "Running…" })] }, call.id))))] })] }));
|
|
1049
|
+
}
|