@hanzo/rooms 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Chat.js +174 -32
- package/dist/Contents.d.ts +23 -0
- package/dist/Contents.js +84 -0
- package/dist/Drive.js +131 -39
- package/dist/Meet.js +26 -7
- package/dist/RightPane.d.ts +1 -1
- package/dist/RightPane.js +26 -5
- package/dist/lib/attach.d.ts +40 -33
- package/dist/lib/attach.js +93 -58
- package/dist/lib/files.d.ts +162 -0
- package/dist/lib/files.js +289 -0
- package/dist/pane.d.ts +39 -17
- package/dist/pane.js +125 -29
- package/package.json +3 -3
package/dist/Chat.js
CHANGED
|
@@ -33,8 +33,9 @@ import { Prose } from "./Prose.js";
|
|
|
33
33
|
import { useAgent, useAi, useChat, useModels, } from "@hanzo/ai/react";
|
|
34
34
|
import { freeCopy, grantConsent, hasConsent, isFree, } from "@hanzo/ai";
|
|
35
35
|
import { ASK, Chat as ChatSurface, Code, Failure, Parts, words } from "@hanzo/ui/chat";
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
36
|
+
import { carriedIn, compose, partsOf, said as told, weigh } from "./lib/attach.js";
|
|
37
|
+
import { dropped, readable, refOf, retrieve } from "./lib/files.js";
|
|
38
|
+
import { channel, drop, hold, landed, spend, take, usePane, useWorkFile } from "./pane.js";
|
|
38
39
|
import { Face, brief as roomBrief, join, roleOf, roomTurn, roster as rosterLine, rosterOf, speakers, speaksOf, voiceOf } from "./cast.js";
|
|
39
40
|
import { SpeakButton, cleanForSpeech } from "./speech.js";
|
|
40
41
|
import { Crew, HOUSE, useCrew } from "./crew.js";
|
|
@@ -48,8 +49,8 @@ import { Meters } from "./meters.js";
|
|
|
48
49
|
import { useHydrated } from "./lib/hydrated.js";
|
|
49
50
|
import { base, served } from "./lib/ai.js";
|
|
50
51
|
import { onServed } from "./lib/served.js";
|
|
51
|
-
import { speech, useVoice, Voice } from "@hanzo/voice";
|
|
52
|
-
import { ArrowUp, Image as ImageMark, Mic, Paperclip, Square, Star, PanelRight, X } from "lucide-react";
|
|
52
|
+
import { REFUSED, speech, useDictation, useTalk, useVoice, Voice } from "@hanzo/voice";
|
|
53
|
+
import { ArrowUp, AudioLines, Image as ImageMark, Mic, Paperclip, Square, Star, PanelRight, X } from "lucide-react";
|
|
53
54
|
import { ENSO, FREE } from "./lib/ai.js";
|
|
54
55
|
import { openThread, useOpen } from "./open.js";
|
|
55
56
|
import { Beside, Framed } from "./Shell.js";
|
|
@@ -296,6 +297,75 @@ function Why({ text }) {
|
|
|
296
297
|
* writes, which streams no words while it does.
|
|
297
298
|
*/
|
|
298
299
|
const STALL = 300_000;
|
|
300
|
+
/**
|
|
301
|
+
* How long a send waits for a freshly attached file to have passages before it
|
|
302
|
+
* goes without them. Long enough for a page or a short document to be read; a
|
|
303
|
+
* large one keeps indexing, and the turn says it is not readable yet.
|
|
304
|
+
*/
|
|
305
|
+
const READ_WAIT = 45_000;
|
|
306
|
+
/** Files named the way a sentence lists them. */
|
|
307
|
+
function names(list) {
|
|
308
|
+
const all = list.map((f) => f.name);
|
|
309
|
+
return all.length <= 2 ? all.join(" and ") : `${all.slice(0, 2).join(", ")} and ${all.length - 2} more`;
|
|
310
|
+
}
|
|
311
|
+
/** Why the files had no passages to give, from what the index last said of each. */
|
|
312
|
+
function unread(list) {
|
|
313
|
+
const parts = list.map((f) => f.status === "stored"
|
|
314
|
+
? `${f.name} is kept but not indexed${f.error ? `: ${f.error}` : ""}`
|
|
315
|
+
: f.status === "failed"
|
|
316
|
+
? `${f.name} could not be indexed${f.error ? `: ${f.error}` : ""}`
|
|
317
|
+
: f.status !== "ready"
|
|
318
|
+
? `${f.name} is still being indexed`
|
|
319
|
+
: null);
|
|
320
|
+
const said = parts.filter(Boolean).join("; ");
|
|
321
|
+
return said || undefined;
|
|
322
|
+
}
|
|
323
|
+
/** How far a file has got, in a word or two. */
|
|
324
|
+
function progress(state, sent, f) {
|
|
325
|
+
if (state === "uploading")
|
|
326
|
+
return `${Math.floor(sent * 100)}%`;
|
|
327
|
+
if (state === "queued")
|
|
328
|
+
return "queued";
|
|
329
|
+
if (state === "indexing") {
|
|
330
|
+
switch (f?.stage) {
|
|
331
|
+
case "extract":
|
|
332
|
+
return "reading";
|
|
333
|
+
case "toc":
|
|
334
|
+
return "contents";
|
|
335
|
+
case "passages":
|
|
336
|
+
return "passages";
|
|
337
|
+
case "embed":
|
|
338
|
+
return f.passages ? `indexing ${Math.floor(((f.embedded ?? 0) / f.passages) * 100)}%` : "indexing";
|
|
339
|
+
case "graph":
|
|
340
|
+
return "linking";
|
|
341
|
+
default:
|
|
342
|
+
return "indexing";
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return state;
|
|
346
|
+
}
|
|
347
|
+
/** One chip: the file's glyph, its name, and how far it has got. */
|
|
348
|
+
function Chip({ name, type, state, sent, file, onRemove, }) {
|
|
349
|
+
const bad = state === "failed";
|
|
350
|
+
const why = state === "failed" || state === "stored" ? file?.error : undefined;
|
|
351
|
+
return (_jsxs(XStack, { role: "listitem", items: "center", gap: "$1.5", pl: "$2.5", pr: onRemove ? "$1" : "$2.5", py: "$1", rounded: "$10", borderWidth: 1, borderColor: "$borderColor", bg: "$hover", maxW: 320, "data-slot": "file-chip", "data-state": state, "data-stage": file?.stage ?? "", ...(why ? { title: why } : null), children: [type.startsWith("image/") ? (_jsx(ImageMark, { size: 12, "aria-hidden": true, color: "var(--muted-foreground)" })) : (_jsx(Paperclip, { size: 12, "aria-hidden": true, color: "var(--muted-foreground)" })), _jsx(Text, { fontSize: "$2", color: "$ink", numberOfLines: 1, children: name }), _jsx(Text, { fontSize: "$1", color: bad ? BAD : "$soft", numberOfLines: 1, shrink: 0, children: progress(state, sent, file) }), onRemove ? (_jsx(Box, { render: "button", "aria-label": `Remove ${name}`, onClick: onRemove, p: "$1", rounded: "$10", hoverStyle: { bg: "$raised" }, children: _jsx(X, { size: 12, "aria-hidden": true }) })) : null] }));
|
|
352
|
+
}
|
|
353
|
+
/** A file held for the next message: uploading, then indexing, then ready. */
|
|
354
|
+
function HeldChip({ one, onRemove }) {
|
|
355
|
+
const live = useWorkFile(one.file?.id);
|
|
356
|
+
const file = live ?? one.file;
|
|
357
|
+
const state = one.state === "uploading" ? one.state : (file?.status ?? one.state);
|
|
358
|
+
return _jsx(Chip, { name: one.name, type: one.type, state: state, sent: one.sent, file: file ?? (one.error ? { error: one.error } : undefined), onRemove: onRemove });
|
|
359
|
+
}
|
|
360
|
+
/** A file a sent turn carried, as the index says of it now. */
|
|
361
|
+
function TurnChip({ f }) {
|
|
362
|
+
const live = useWorkFile(f.id);
|
|
363
|
+
return _jsx(Chip, { name: f.name, type: f.type, state: live?.status ?? "ready", sent: 1, file: live });
|
|
364
|
+
}
|
|
365
|
+
/** A question that carried files: its words, the files put into it, and those read again. */
|
|
366
|
+
function Carried({ text, attached, reused }) {
|
|
367
|
+
return (_jsxs(YStack, { gap: "$1.5", "data-slot": "turn-files", children: [text ? _jsx(Prose, { text: text }) : null, attached.length ? (_jsx(XStack, { gap: "$1.5", flexWrap: "wrap", role: "list", "aria-label": "Files in this message", children: attached.map((f) => (_jsx(TurnChip, { f: f }, f.id))) })) : null, reused.length ? (_jsxs(Text, { fontSize: "$1", color: "$soft", children: ["Read again: ", reused.map((f) => `${f.name} (${weigh(f.size)})`).join(", ")] })) : null] }));
|
|
368
|
+
}
|
|
299
369
|
/**
|
|
300
370
|
* The conversation. One component for a visitor and for an account.
|
|
301
371
|
*
|
|
@@ -598,9 +668,7 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
|
|
|
598
668
|
// identity changes on every `setMessages` — `useChat` closes over `messages` —
|
|
599
669
|
// so an effect depending on it re-runs after each streamed token.
|
|
600
670
|
const latest = useRef(send);
|
|
601
|
-
|
|
602
|
-
// not, and are spent once: a retry is the words again, not the pictures.
|
|
603
|
-
latest.current = (text) => send(text, outgoing.current.splice(0));
|
|
671
|
+
latest.current = (text) => send(text);
|
|
604
672
|
// THE COMPOSER'S DRAFT IS HELD HERE, so an arriving question can be typed into
|
|
605
673
|
// it. `Chat` owns a draft of its own and spreads `composer` after it, so
|
|
606
674
|
// passing value/onChange/onSend takes that ownership over rather than fighting
|
|
@@ -609,26 +677,33 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
|
|
|
609
677
|
// FILES WITH THE NEXT MESSAGE, held by `pane.ts` for this conversation: the
|
|
610
678
|
// paperclip, a drop anywhere on the room, a paste into the field and the
|
|
611
679
|
// column's `+` all go through its one `hold`, so the chips here and the rows
|
|
612
|
-
// under Sources are one list.
|
|
613
|
-
//
|
|
680
|
+
// under Sources are one list. Each goes to the org's workspace and is indexed
|
|
681
|
+
// there; the turn carries it by reference (lib/attach), with the passages
|
|
682
|
+
// read from it for the question.
|
|
614
683
|
const { held: files, refused: turnedAway } = usePane(at);
|
|
684
|
+
const via = useMemo(() => ({ api: ai, org: anonymous ? null : org() }), [ai, anonymous]);
|
|
685
|
+
// What the send is waiting on — uploads landing, the files being read — said
|
|
686
|
+
// over the field, and the reason a second press does nothing meanwhile.
|
|
687
|
+
const [preparing, setPreparing] = useState(null);
|
|
615
688
|
const [dragging, setDragging] = useState(false);
|
|
616
|
-
const outgoing = useRef([]);
|
|
617
689
|
const depth = useRef(0);
|
|
618
690
|
useSeed(initial, setDraft);
|
|
619
691
|
// DICTATION through Hanzo's own ear: the mic records and each pause is
|
|
620
|
-
// posted to /v1/audio/transcriptions (`zen-scribe`)
|
|
621
|
-
// the
|
|
622
|
-
//
|
|
623
|
-
// the
|
|
624
|
-
//
|
|
692
|
+
// posted to /v1/audio/transcriptions (`zen-scribe`), and what was said lands
|
|
693
|
+
// AFTER what the draft already held (`useDictation`) — a pause between two
|
|
694
|
+
// sentences never loses the first. `speech()` defaults to the Hanzo gateway
|
|
695
|
+
// and `base()` is the one place a host is read from, so pointing a local
|
|
696
|
+
// cloud at one points both. The token is the reader's own — the package never
|
|
697
|
+
// invents a credential — and a refusal is worn by `<Voice/>`, never passed off
|
|
698
|
+
// as a transcript.
|
|
625
699
|
const { token } = useIamToken();
|
|
626
700
|
const ear = useMemo(() => speech({ ...base(), ...(token ? { token } : {}) }), [token]);
|
|
627
|
-
const voice = useVoice({
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
701
|
+
const voice = useVoice({ speech: ear, ...useDictation(draft, setDraft) });
|
|
702
|
+
// TALK MODE: a hands-free conversation on Hanzo's realtime socket, /v1/voice.
|
|
703
|
+
// The mic streams up, the answer streams back as speech with its words in
|
|
704
|
+
// `talk.reply`, and talking over it stops it. Its own conversation, so its
|
|
705
|
+
// turns are drawn above the composer rather than posted into the thread.
|
|
706
|
+
const talk = useTalk({ ...base(), ...(token ? { token } : {}), org: org() ?? undefined });
|
|
632
707
|
// WHILE THE MIC IS OPEN, A FINISHED REPLY IS READ ALOUD by the platform's
|
|
633
708
|
// voice, through the conversation's own mouth so speaking over it stops it.
|
|
634
709
|
// A room's reply is read part by part, each in its speaker's cast voice.
|
|
@@ -639,11 +714,12 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
|
|
|
639
714
|
const lastMsg = msgs[msgs.length - 1];
|
|
640
715
|
if (lastMsg && lastMsg.role === "assistant" && typeof lastMsg.content === "string") {
|
|
641
716
|
const parts = room.length > 1 ? speakers(lastMsg.content, room) : [{ who: withWhom, text: lastMsg.content }];
|
|
717
|
+
// A part spoken over ends the reading rather than handing on to the next.
|
|
642
718
|
void (async () => {
|
|
643
719
|
for (const part of parts) {
|
|
644
720
|
const said = cleanForSpeech(part.text);
|
|
645
|
-
if (said)
|
|
646
|
-
|
|
721
|
+
if (said && !(await voice.say(said, voiceOf(part.who ?? withWhom ?? undefined))))
|
|
722
|
+
break;
|
|
647
723
|
}
|
|
648
724
|
})();
|
|
649
725
|
}
|
|
@@ -790,15 +866,63 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
|
|
|
790
866
|
}
|
|
791
867
|
speak(text);
|
|
792
868
|
}, [model, speak]);
|
|
793
|
-
/**
|
|
869
|
+
/**
|
|
870
|
+
* The draft and the files, sent as one turn.
|
|
871
|
+
*
|
|
872
|
+
* A file goes by REFERENCE: the send waits for each held file's upload to
|
|
873
|
+
* land, then asks the index for the passages that answer the question —
|
|
874
|
+
* across the files put into this turn and every file earlier turns carried —
|
|
875
|
+
* and the turn carries their names and those passages, each citing
|
|
876
|
+
* file › section › ¶n. A file still indexing is waited on for a bounded
|
|
877
|
+
* while; past it the turn says the file is not readable yet rather than
|
|
878
|
+
* answering as though it had been read.
|
|
879
|
+
*/
|
|
794
880
|
const submit = () => {
|
|
795
881
|
const text = draft.trim();
|
|
796
|
-
if (!text && !files.length)
|
|
882
|
+
if (preparing || (!text && !files.length))
|
|
797
883
|
return;
|
|
798
|
-
const
|
|
799
|
-
|
|
884
|
+
const holding = files.slice();
|
|
885
|
+
const earlier = carriedIn(chat.messages);
|
|
886
|
+
if (!holding.length && !earlier.length) {
|
|
887
|
+
setDraft("");
|
|
888
|
+
guarded(text);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
800
891
|
setDraft("");
|
|
801
|
-
|
|
892
|
+
void (async () => {
|
|
893
|
+
try {
|
|
894
|
+
if (holding.some((h) => !h.file))
|
|
895
|
+
setPreparing(`Uploading ${names(holding)}`);
|
|
896
|
+
const landedFiles = (await Promise.all(holding.map(landed))).filter((f) => !!f);
|
|
897
|
+
spend(at);
|
|
898
|
+
const attached = landedFiles.map(refOf);
|
|
899
|
+
const reused = earlier.filter((f) => !attached.some((a) => a.id === f.id));
|
|
900
|
+
const all = [...attached, ...reused];
|
|
901
|
+
if (!all.length) {
|
|
902
|
+
if (text)
|
|
903
|
+
guarded(text);
|
|
904
|
+
else
|
|
905
|
+
setDraft(text);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
setPreparing(`Reading ${names(all)}`);
|
|
909
|
+
const now = await readable(ai, landedFiles, READ_WAIT);
|
|
910
|
+
let grounds = null;
|
|
911
|
+
let why;
|
|
912
|
+
try {
|
|
913
|
+
grounds = await retrieve(ai, text || `What is in ${names(all)}?`, all.map((f) => f.id), 8);
|
|
914
|
+
}
|
|
915
|
+
catch {
|
|
916
|
+
why = "the workspace could not be read just now";
|
|
917
|
+
}
|
|
918
|
+
if (!grounds?.passages.length)
|
|
919
|
+
why = why ?? unread(now);
|
|
920
|
+
guarded(compose(text, { attached, reused }, grounds, why));
|
|
921
|
+
}
|
|
922
|
+
finally {
|
|
923
|
+
setPreparing(null);
|
|
924
|
+
}
|
|
925
|
+
})();
|
|
802
926
|
};
|
|
803
927
|
/*
|
|
804
928
|
WHAT AN UNANSWERED QUESTION LOOKS LIKE: a turn of its own directly under it,
|
|
@@ -908,7 +1032,8 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
|
|
|
908
1032
|
if (!e.dataTransfer.files?.length)
|
|
909
1033
|
return;
|
|
910
1034
|
e.preventDefault();
|
|
911
|
-
|
|
1035
|
+
// A dropped folder arrives as entries; each file under it goes up with its path.
|
|
1036
|
+
void dropped(e.dataTransfer).then((list) => hold(at, list, via));
|
|
912
1037
|
}, children: [dragging ? (_jsx(YStack, { "aria-hidden": true, position: "absolute", t: 0, r: 0, b: 0, l: 0, z: 10, items: "center", justify: "center", bg: "var(--surface-scrim)", borderWidth: 2, borderStyle: "dashed", borderColor: "$borderColor", rounded: "var(--pane-round)", pointerEvents: "none", children: _jsxs(XStack, { items: "center", gap: "$2", px: "$4", py: "$2.5", rounded: "$10", bg: "$background", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(Paperclip, { size: 15, "aria-hidden": true }), _jsx(Text, { fontSize: "$4", color: "$ink", children: "Drop files to attach" })] }) })) : null, _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: () => {
|
|
913
1038
|
if (aside === null) {
|
|
914
1039
|
showAside("tasks");
|
|
@@ -946,6 +1071,13 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
|
|
|
946
1071
|
if (turn.role === "user" && Array.isArray(turn.content))
|
|
947
1072
|
return _jsx(Parts, { parts: partsOf(turn.content), prose: (text) => _jsx(Prose, { text: text }) });
|
|
948
1073
|
const said = typeof turn.content === "string" ? turn.content : "";
|
|
1074
|
+
// A QUESTION THAT CARRIED FILES draws its words and the files' chips;
|
|
1075
|
+
// the passages it handed the model are the model's to read, not ours.
|
|
1076
|
+
if (turn.role === "user" && said) {
|
|
1077
|
+
const read = told(said);
|
|
1078
|
+
if (read.carried)
|
|
1079
|
+
return _jsx(Carried, { text: read.text, attached: read.carried.attached, reused: read.carried.reused });
|
|
1080
|
+
}
|
|
949
1081
|
// WHO IS ANSWERING, while the answer has no words yet: the agents the
|
|
950
1082
|
// question addresses, or the whole room when it names nobody.
|
|
951
1083
|
if (turn.role === "assistant" && !said && chat.streaming && room.length) {
|
|
@@ -1034,7 +1166,7 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
|
|
|
1034
1166
|
// `Control` circle as the mic, filled.
|
|
1035
1167
|
// THE PAPERCLIP is the column's "Attach files", one press closer: the
|
|
1036
1168
|
// same `take`, into the same `hold`.
|
|
1037
|
-
children: (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { children: _jsx(Control, { type: "button", size: ROUND, "aria-label": "Attach files", onClick: () => take(at), children: _jsx(Paperclip, { size: 15, "aria-hidden": true }) }) }), _jsx(TooltipContent, { children: _jsx(Text, { fontSize: "$2", children: "Attach files" }) })] })),
|
|
1169
|
+
children: (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { children: _jsx(Control, { type: "button", size: ROUND, "aria-label": "Attach files", onClick: () => take(at, via), children: _jsx(Paperclip, { size: 15, "aria-hidden": true }) }) }), _jsx(TooltipContent, { children: _jsx(Text, { fontSize: "$2", children: "Attach files" }) })] })),
|
|
1038
1170
|
// A file or an image pasted into the field is held like a picked one;
|
|
1039
1171
|
// words paste as words.
|
|
1040
1172
|
field: {
|
|
@@ -1042,13 +1174,23 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
|
|
|
1042
1174
|
if (!e.clipboardData?.files?.length)
|
|
1043
1175
|
return;
|
|
1044
1176
|
e.preventDefault();
|
|
1045
|
-
void hold(at, Array.from(e.clipboardData.files));
|
|
1177
|
+
void hold(at, Array.from(e.clipboardData.files), via);
|
|
1046
1178
|
},
|
|
1047
1179
|
},
|
|
1048
1180
|
// WHAT GOES WITH THE MESSAGE, above the frame: one chip a file, each
|
|
1049
1181
|
// with its own remove, and the reason any file was refused.
|
|
1050
|
-
head: files.length || turnedAway ? (_jsxs(YStack, { width: "calc(100% - 32px)", maxW: `calc(${MEASURE} - 3rem)`, mx: "auto", gap: "$1.5", children: [files.length ? (_jsx(XStack, { gap: "$1.5", flexWrap: "wrap", role: "list", "aria-label": "Attached files", "data-slot": "composer-attached", children: files.map((one) => (
|
|
1051
|
-
|
|
1182
|
+
head: files.length || turnedAway || preparing || talk.open || talk.refusal ? (_jsxs(YStack, { width: "calc(100% - 32px)", maxW: `calc(${MEASURE} - 3rem)`, mx: "auto", gap: "$1.5", children: [files.length ? (_jsx(XStack, { gap: "$1.5", flexWrap: "wrap", role: "list", "aria-label": "Attached files", "data-slot": "composer-attached", children: files.map((one) => (_jsx(HeldChip, { one: one, onRemove: () => drop(at, one.id) }, one.id))) })) : null, preparing ? (_jsxs(Text, { role: "status", "aria-live": "polite", "data-slot": "composer-preparing", fontSize: "$2", color: "$soft", children: [preparing, "\u2026"] })) : null, turnedAway ? (_jsx(Text, { role: "alert", fontSize: "$2", color: BAD, children: turnedAway })) : null, talk.open || talk.refusal ? (_jsx(Text, { role: "status", "aria-live": "polite", "data-slot": "composer-talk-reply", fontSize: "$2", color: talk.refusal ? BAD : "$soft", children: talk.refusal
|
|
1183
|
+
? REFUSED.lost
|
|
1184
|
+
: talk.reply
|
|
1185
|
+
? `Hanzo: ${talk.reply}`
|
|
1186
|
+
: talk.state === "listening"
|
|
1187
|
+
? "Listening — speak, and Hanzo answers aloud."
|
|
1188
|
+
: "Connecting…" })) : null] })) : undefined,
|
|
1189
|
+
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, says: { idle: "Dictate", listening: "Dictating — click to stop" }, children: (state) => (_jsx(Mic, { size: 15, fill: state === "idle" ? "none" : "currentColor" })) }) }), _jsx(Control, { asChild: true, size: ROUND, children: _jsx(Voice, { voice: talk, "data-slot": "composer-talk", says: {
|
|
1190
|
+
idle: "Talk with Hanzo",
|
|
1191
|
+
listening: "In conversation — click to hang up",
|
|
1192
|
+
speaking: "Hanzo is speaking — talk to interrupt",
|
|
1193
|
+
}, children: () => _jsx(AudioLines, { size: 15 }) }) }), _jsx(Control, { type: "button", size: ROUND, fill: true, "data-slot": "composer-send", "aria-label": chat.streaming ? "Stop" : "Send", opacity: chat.streaming || draft.trim() || files.length ? 1 : 0.4, onClick: () => (chat.streaming ? chat.stop() : submit()), children: chat.streaming ? (_jsx(Square, { size: 13, fill: "currentColor" })) : (_jsx(ArrowUp, { size: 15, strokeWidth: 2.5 })) })] })),
|
|
1052
1194
|
// WHAT THE PLAN HAS LEFT, under the frame and at its measure, for a
|
|
1053
1195
|
// plan holder only: Free keeps the free lane's own notice.
|
|
1054
1196
|
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,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type Api, type Passage, type WorkFile } from './lib/files.js';
|
|
2
|
+
/** How far a file's ingest has got, as Drive's badge and this pane's head say it. */
|
|
3
|
+
export declare function standing(f: WorkFile): string;
|
|
4
|
+
/** The badge a Drive row wears for its index state. */
|
|
5
|
+
export declare function Badge({ f }: {
|
|
6
|
+
f: WorkFile;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
8
|
+
/** Passages of the workspace that match a search, each naming where it is. */
|
|
9
|
+
export declare function Hits({ hits, onOpen }: {
|
|
10
|
+
hits: Passage[];
|
|
11
|
+
onOpen: (p: Passage) => void;
|
|
12
|
+
}): import("react").JSX.Element;
|
|
13
|
+
/** The pane: a file's tree, and one section opened from it. */
|
|
14
|
+
export declare function Contents({ api, file, at, onClose, onAsk, onFollow, }: {
|
|
15
|
+
api: Api;
|
|
16
|
+
file: WorkFile;
|
|
17
|
+
/** The section to open on arrival, when a search hit brought the reader here. */
|
|
18
|
+
at?: number;
|
|
19
|
+
onClose: () => void;
|
|
20
|
+
onAsk: (f: WorkFile) => void;
|
|
21
|
+
/** Follows a link into another file. */
|
|
22
|
+
onFollow: (file: string, section: number) => void;
|
|
23
|
+
}): import("react").JSX.Element;
|
package/dist/Contents.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// A workspace file's table of contents, opened beside Drive.
|
|
4
|
+
//
|
|
5
|
+
// The index reads every file into sections — a page's headings, a PDF's outline,
|
|
6
|
+
// a document's heading styles, a sheet's rows, a long text cut into parts — each
|
|
7
|
+
// with a one-line summary. This pane draws that tree, opens a section's own
|
|
8
|
+
// text, and follows the graph from it: the sections it links to, the files that
|
|
9
|
+
// cite it, the entities it shares with the rest of the workspace. It is the same
|
|
10
|
+
// tree `retrieve` reads before it picks where to look, so what a person browses
|
|
11
|
+
// here is what an answer cites.
|
|
12
|
+
import { useEffect, useState } from 'react';
|
|
13
|
+
import { ArrowLeft, MessageSquare, X } from 'lucide-react';
|
|
14
|
+
import { Box, Button, Text, XStack, YStack } from '@hanzo/ui';
|
|
15
|
+
import { section as openSection, toc } from './lib/files.js';
|
|
16
|
+
import { BAD } from './lib/mix.js';
|
|
17
|
+
/** How far a file's ingest has got, as Drive's badge and this pane's head say it. */
|
|
18
|
+
export function standing(f) {
|
|
19
|
+
switch (f.status) {
|
|
20
|
+
case 'ready':
|
|
21
|
+
return `Indexed · ${f.sections ?? 0} sections`;
|
|
22
|
+
case 'stored':
|
|
23
|
+
return 'Kept, not indexed';
|
|
24
|
+
case 'failed':
|
|
25
|
+
return 'Indexing failed';
|
|
26
|
+
case 'queued':
|
|
27
|
+
return 'Queued';
|
|
28
|
+
default:
|
|
29
|
+
if (f.stage === 'embed' && f.passages)
|
|
30
|
+
return `Indexing ${Math.floor(((f.embedded ?? 0) / f.passages) * 100)}%`;
|
|
31
|
+
return `Indexing · ${f.stage ?? 'starting'}`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** The badge a Drive row wears for its index state. */
|
|
35
|
+
export function Badge({ f }) {
|
|
36
|
+
const bad = f.status === 'failed';
|
|
37
|
+
return (_jsx(Text, { fontSize: "$1", color: bad ? BAD : f.status === 'ready' ? '$soft' : '$faint', numberOfLines: 1, "data-slot": "drive-index", "data-state": f.status, "data-stage": f.stage ?? '', ...(f.error ? { title: f.error } : null), children: standing(f) }));
|
|
38
|
+
}
|
|
39
|
+
/** Passages of the workspace that match a search, each naming where it is. */
|
|
40
|
+
export function Hits({ hits, onOpen }) {
|
|
41
|
+
return (_jsxs(YStack, { gap: "$2", "data-slot": "drive-passages", children: [_jsx(Text, { fontSize: 12, fontWeight: "600", color: "$soft", children: "Passages" }), _jsx(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: 8, overflow: "hidden", children: hits.map((p, i) => (_jsxs(YStack, { px: 16, py: 10, gap: "$1", cursor: "pointer", borderBottomWidth: i === hits.length - 1 ? 0 : 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, onPress: () => onOpen(p), "data-slot": "drive-passage", children: [_jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$ink", numberOfLines: 1, children: p.cite }), _jsx(Text, { fontSize: "$2", color: "$soft", numberOfLines: 3, children: p.text })] }, `${p.file.id}#${p.section.id}.${p.part}.${i}`))) })] }));
|
|
42
|
+
}
|
|
43
|
+
/** The pane: a file's tree, and one section opened from it. */
|
|
44
|
+
export function Contents({ api, file, at, onClose, onAsk, onFollow, }) {
|
|
45
|
+
const [tree, setTree] = useState(null);
|
|
46
|
+
const [failed, setFailed] = useState(null);
|
|
47
|
+
const [open, setOpen] = useState(null);
|
|
48
|
+
const [reading, setReading] = useState(at ?? null);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
let live = true;
|
|
51
|
+
setTree(null);
|
|
52
|
+
setFailed(null);
|
|
53
|
+
toc(api, file.id)
|
|
54
|
+
.then((t) => live && setTree(t))
|
|
55
|
+
.catch((e) => live && setFailed(e?.message || 'The contents could not be read.'));
|
|
56
|
+
return () => {
|
|
57
|
+
live = false;
|
|
58
|
+
};
|
|
59
|
+
}, [api, file.id]);
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
setReading(at ?? null);
|
|
62
|
+
}, [at, file.id]);
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (reading === null) {
|
|
65
|
+
setOpen(null);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
let live = true;
|
|
69
|
+
openSection(api, file.id, reading)
|
|
70
|
+
.then((o) => live && setOpen(o))
|
|
71
|
+
.catch((e) => live && setFailed(e?.message || 'The section could not be opened.'));
|
|
72
|
+
return () => {
|
|
73
|
+
live = false;
|
|
74
|
+
};
|
|
75
|
+
}, [api, file.id, reading]);
|
|
76
|
+
const shown = tree?.file ?? file;
|
|
77
|
+
return (_jsxs(YStack, { position: "absolute", t: 0, r: 0, b: 0, width: 440, maxW: "100%", bg: "$background", borderLeftWidth: 1, borderColor: "$borderColor", boxShadow: "var(--shadow-lg)", z: "var(--z-drawer)", "data-slot": "drive-contents", children: [_jsxs(XStack, { items: "center", justify: "space-between", gap: "$2", px: "$4", py: "$3", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsxs(YStack, { minW: 0, flex: 1, children: [_jsx(Text, { fontSize: "$3", fontWeight: "600", color: "$ink", numberOfLines: 1, children: shown.name }), _jsx(Text, { fontSize: "$1", color: "$soft", numberOfLines: 1, children: standing(shown) })] }), _jsxs(Button, { size: "sm", onClick: () => onAsk(shown), "aria-label": "Ask in chat", children: [_jsx(MessageSquare, { size: 14 }), "Ask in chat"] }), _jsx(Button, { variant: "ghost", size: "icon-sm", onClick: onClose, "aria-label": "Close contents", title: "Close", children: _jsx(X, { size: 15 }) })] }), _jsxs(YStack, { flex: 1, minH: 0, overflowY: "auto", px: "$4", py: "$3", gap: "$2", children: [failed ? (_jsx(Text, { fontSize: "$2", color: BAD, children: failed })) : null, open ? (_jsxs(YStack, { gap: "$3", "data-slot": "drive-section", children: [_jsxs(Button, { variant: "ghost", size: "sm", justify: "flex-start", px: 0, onClick: () => setReading(null), children: [_jsx(ArrowLeft, { size: 13 }), "Contents"] }), _jsx(Text, { fontSize: "$1", color: "$soft", children: open.section.path }), _jsx(Text, { fontSize: "$4", fontWeight: "600", color: "$ink", children: open.section.title }), open.summary ? (_jsx(Text, { fontSize: "$2", color: "$soft", children: open.summary })) : null, _jsx(Text, { fontSize: "$2", color: "$ink", whiteSpace: "pre-wrap", children: open.text }), open.children.length ? (_jsxs(YStack, { gap: "$1", children: [_jsx(Text, { fontSize: 11, fontWeight: "600", color: "$faint", letterSpacing: 0.6, children: "Subsections" }), open.children.map((c) => (_jsx(Entry, { e: c, onOpen: () => setReading(c.id) }, c.id)))] })) : null, open.links.length ? (_jsxs(YStack, { gap: "$1", "data-slot": "drive-links", children: [_jsx(Text, { fontSize: 11, fontWeight: "600", color: "$faint", letterSpacing: 0.6, children: "Linked" }), open.links.map((l, i) => (_jsxs(Box, { render: "button", items: "flex-start", py: "$1", onClick: () => (l.file.id === file.id ? setReading(l.section.id) : onFollow(l.file.id, l.section.id)), children: [_jsx(Text, { fontSize: "$2", color: "$ink", numberOfLines: 1, children: l.file.id === file.id ? l.section.path || l.section.title : `${l.file.name} › ${l.section.path || l.section.title}` }), _jsxs(Text, { fontSize: "$1", color: "$soft", numberOfLines: 1, children: [l.kind, l.label ? ` · ${l.label}` : ''] })] }, `${l.file.id}#${l.section.id}.${i}`)))] })) : null, open.entities.length ? (_jsxs(Text, { fontSize: "$1", color: "$soft", children: ["Names: ", open.entities.map((e) => e.name).join(', ')] })) : null] })) : !tree ? (failed ? null : (_jsx(Text, { fontSize: "$2", color: "$soft", children: "Reading the contents\u2026" }))) : tree.sections.length <= 1 ? (_jsx(Text, { fontSize: "$2", color: "$soft", children: shown.status === 'ready' ? 'This file has no sections.' : 'The contents appear once the file is indexed.' })) : (tree.sections
|
|
78
|
+
.filter((e) => e.level > 0)
|
|
79
|
+
.map((e) => _jsx(Entry, { e: e, onOpen: () => setReading(e.id) }, e.id)))] })] }));
|
|
80
|
+
}
|
|
81
|
+
/** One line of the tree, indented by its depth. */
|
|
82
|
+
function Entry({ e, onOpen }) {
|
|
83
|
+
return (_jsxs(Box, { render: "button", items: "flex-start", pl: (Math.max(1, e.level) - 1) * 14, py: "$1", onClick: onOpen, "data-slot": "drive-toc-entry", children: [_jsx(Text, { fontSize: "$2", color: "$ink", numberOfLines: 1, children: e.title }), e.summary ? (_jsx(Text, { fontSize: "$1", color: "$soft", numberOfLines: 2, children: e.summary })) : null] }));
|
|
84
|
+
}
|