@hanzo/rooms 0.1.4 → 0.1.6
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 +33 -31
- package/dist/RightPane.js +21 -30
- package/dist/lib/attach.d.ts +19 -14
- package/dist/lib/attach.js +12 -9
- package/dist/open.d.ts +2 -2
- package/dist/open.js +3 -3
- package/dist/pane.d.ts +31 -4
- package/dist/pane.js +59 -11
- package/package.json +1 -1
package/dist/Chat.js
CHANGED
|
@@ -33,12 +33,13 @@ 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 { compose,
|
|
36
|
+
import { compose, images, partsOf } from "./lib/attach.js";
|
|
37
|
+
import { channel, drop, hold, spend, take, usePane } from "./pane.js";
|
|
37
38
|
import { Face, brief as roomBrief, join, roleOf, roomTurn, roster as rosterLine, rosterOf, speakers, speaksOf, voiceOf } from "./cast.js";
|
|
38
39
|
import { SpeakButton, cleanForSpeech } from "./speech.js";
|
|
39
40
|
import { Crew, HOUSE, useCrew } from "./crew.js";
|
|
40
41
|
import { called } from "./team.js";
|
|
41
|
-
import { Anchor, Box, Button, Text, View, XStack, YStack } from "@hanzo/ui";
|
|
42
|
+
import { Anchor, Box, Button, Text, Tooltip, TooltipContent, TooltipTrigger, View, XStack, YStack } from "@hanzo/ui";
|
|
42
43
|
import { Control } from "@hanzo/composer";
|
|
43
44
|
import { useIam, useIamToken } from "@hanzo/iam/react";
|
|
44
45
|
import { hasSession, org } from "./lib/session.js";
|
|
@@ -48,7 +49,7 @@ import { useHydrated } from "./lib/hydrated.js";
|
|
|
48
49
|
import { base, served } from "./lib/ai.js";
|
|
49
50
|
import { onServed } from "./lib/served.js";
|
|
50
51
|
import { speech, useVoice, Voice } from "@hanzo/voice";
|
|
51
|
-
import { ArrowUp, Mic, Paperclip, Square, Star, PanelRight, X } from "lucide-react";
|
|
52
|
+
import { ArrowUp, Image as ImageMark, Mic, Paperclip, Square, Star, PanelRight, X } from "lucide-react";
|
|
52
53
|
import { ENSO, FREE } from "./lib/ai.js";
|
|
53
54
|
import { openThread, useOpen } from "./open.js";
|
|
54
55
|
import { Beside, Framed } from "./Shell.js";
|
|
@@ -331,7 +332,10 @@ function Thread({ placeholder = MESSAGE, initial, anonymous = false, heading, })
|
|
|
331
332
|
const ai = useAi();
|
|
332
333
|
// In the app's frame the composer floats on the pane as a pane of its own.
|
|
333
334
|
const framed = useContext(Framed);
|
|
334
|
-
const
|
|
335
|
+
const opened = useOpen();
|
|
336
|
+
const { thread: open, open: reopen, agents: room, openAgent, seat, emptied, aside, showAside, showSettings } = opened;
|
|
337
|
+
// The conversation's key in `pane.ts`, the one the column beside it reads.
|
|
338
|
+
const at = channel(opened);
|
|
335
339
|
// The first of them: what a surface that addresses one person reads.
|
|
336
340
|
const withWhom = room[0] ?? null;
|
|
337
341
|
// Whether the column beside this conversation is showing its summary, and the
|
|
@@ -601,23 +605,15 @@ function Thread({ placeholder = MESSAGE, initial, anonymous = false, heading, })
|
|
|
601
605
|
// passing value/onChange/onSend takes that ownership over rather than fighting
|
|
602
606
|
// it.
|
|
603
607
|
const [draft, setDraft] = useState("");
|
|
604
|
-
// FILES WITH THE NEXT MESSAGE
|
|
605
|
-
// anywhere on the room
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
608
|
+
// FILES WITH THE NEXT MESSAGE, held by `pane.ts` for this conversation: the
|
|
609
|
+
// paperclip, a drop anywhere on the room, a paste into the field and the
|
|
610
|
+
// column's `+` all go through its one `hold`, so the chips here and the rows
|
|
611
|
+
// under Sources are one list. An image rides as a picture the model reads, a
|
|
612
|
+
// text file as its words (lib/attach); anything else is refused by name.
|
|
613
|
+
const { held: files, refused: turnedAway } = usePane(at);
|
|
609
614
|
const [dragging, setDragging] = useState(false);
|
|
610
|
-
const picker = useRef(null);
|
|
611
615
|
const outgoing = useRef([]);
|
|
612
616
|
const depth = useRef(0);
|
|
613
|
-
const take = useCallback(async (list) => {
|
|
614
|
-
if (!list?.length)
|
|
615
|
-
return;
|
|
616
|
-
const out = await Promise.all(Array.from(list).map(hold));
|
|
617
|
-
setFiles((now) => [...now, ...out.filter((one) => typeof one !== "string")]);
|
|
618
|
-
const refused = out.filter((one) => typeof one === "string");
|
|
619
|
-
setFileSaid(refused.length ? refused.join(" ") : null);
|
|
620
|
-
}, []);
|
|
621
617
|
useSeed(initial, setDraft);
|
|
622
618
|
// DICTATION through Hanzo's own ear: the mic records and each pause is
|
|
623
619
|
// posted to /v1/audio/transcriptions (`zen-scribe`). `speech()` defaults to
|
|
@@ -798,11 +794,10 @@ function Thread({ placeholder = MESSAGE, initial, anonymous = false, heading, })
|
|
|
798
794
|
const text = draft.trim();
|
|
799
795
|
if (!text && !files.length)
|
|
800
796
|
return;
|
|
801
|
-
|
|
797
|
+
const going = spend(at);
|
|
798
|
+
outgoing.current = images(going);
|
|
802
799
|
setDraft("");
|
|
803
|
-
|
|
804
|
-
setFileSaid(null);
|
|
805
|
-
guarded(compose(text, files));
|
|
800
|
+
guarded(compose(text, going));
|
|
806
801
|
};
|
|
807
802
|
/*
|
|
808
803
|
WHAT AN UNANSWERED QUESTION LOOKS LIKE: a turn of its own directly under it,
|
|
@@ -912,8 +907,8 @@ function Thread({ placeholder = MESSAGE, initial, anonymous = false, heading, })
|
|
|
912
907
|
if (!e.dataTransfer.files?.length)
|
|
913
908
|
return;
|
|
914
909
|
e.preventDefault();
|
|
915
|
-
void
|
|
916
|
-
}, 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: _jsx(Text, { fontSize: "$4", color: "$ink", children: "Drop 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: () => {
|
|
910
|
+
void hold(at, Array.from(e.dataTransfer.files));
|
|
911
|
+
}, 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: () => {
|
|
917
912
|
if (aside === null) {
|
|
918
913
|
showAside("tasks");
|
|
919
914
|
if (!pinned)
|
|
@@ -1036,15 +1031,22 @@ function Thread({ placeholder = MESSAGE, initial, anonymous = false, heading, })
|
|
|
1036
1031
|
// package's button and `streaming` still has to become stop; it keeps
|
|
1037
1032
|
// `data-slot="composer-send"` so a driver finds it, and it is the same
|
|
1038
1033
|
// `Control` circle as the mic, filled.
|
|
1039
|
-
// THE PAPERCLIP
|
|
1040
|
-
// the
|
|
1041
|
-
children: (_jsxs(
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1034
|
+
// THE PAPERCLIP is the column's "Attach files", one press closer: the
|
|
1035
|
+
// same `take`, into the same `hold`.
|
|
1036
|
+
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" }) })] })),
|
|
1037
|
+
// A file or an image pasted into the field is held like a picked one;
|
|
1038
|
+
// words paste as words.
|
|
1039
|
+
field: {
|
|
1040
|
+
onPaste: (e) => {
|
|
1041
|
+
if (!e.clipboardData?.files?.length)
|
|
1042
|
+
return;
|
|
1043
|
+
e.preventDefault();
|
|
1044
|
+
void hold(at, Array.from(e.clipboardData.files));
|
|
1045
|
+
},
|
|
1046
|
+
},
|
|
1045
1047
|
// WHAT GOES WITH THE MESSAGE, above the frame: one chip a file, each
|
|
1046
1048
|
// with its own remove, and the reason any file was refused.
|
|
1047
|
-
head: files.length ||
|
|
1049
|
+
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) => (_jsxs(XStack, { role: "listitem", items: "center", gap: "$1.5", pl: "$2.5", pr: "$1", py: "$1", rounded: "$10", borderWidth: 1, borderColor: "$borderColor", bg: "$hover", maxW: 260, children: [one.kind === "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: one.name }), _jsx(Box, { render: "button", "aria-label": `Remove ${one.name}`, onClick: () => drop(at, one.id), p: "$1", rounded: "$10", hoverStyle: { bg: "$raised" }, children: _jsx(X, { size: 12, "aria-hidden": true }) })] }, one.id))) })) : null, turnedAway ? (_jsx(Text, { role: "alert", fontSize: "$2", color: BAD, children: turnedAway })) : null] })) : undefined,
|
|
1048
1050
|
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() || 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 })) })] })),
|
|
1049
1051
|
// WHAT THE PLAN HAS LEFT, under the frame and at its measure, for a
|
|
1050
1052
|
// plan holder only: Free keeps the free lane's own notice.
|
package/dist/RightPane.js
CHANGED
|
@@ -14,10 +14,12 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
14
14
|
// OUTPUTS AND SOURCES ARE DERIVED FROM THE TURNS, never from a second list kept
|
|
15
15
|
// in step with them. `LLM.md` already states this rule for `Work` and it is the
|
|
16
16
|
// same rule: the conversation is the record, so a fenced block in an answer IS
|
|
17
|
-
// an output and an image in a question
|
|
18
|
-
// nothing to invalidate, and no count that
|
|
19
|
-
// and switching channels costs no request,
|
|
20
|
-
// hand.
|
|
17
|
+
// an output, and an image in a question or a block in it that names a file IS a
|
|
18
|
+
// source. There is nothing to sync, nothing to invalidate, and no count that
|
|
19
|
+
// can disagree with the transcript — and switching channels costs no request,
|
|
20
|
+
// because the turns are already in hand. The one thing listed that is not yet
|
|
21
|
+
// a turn is what is held for the next message (`hold` in `pane.ts`), which a
|
|
22
|
+
// send spends into one. A conversation that has produced nothing says so.
|
|
21
23
|
//
|
|
22
24
|
// THE COLUMN IS STACKED, NOT SPLIT. The reference this follows puts a ~300px
|
|
23
25
|
// summary beside a wide browser, which needs about 900px of column; the frame
|
|
@@ -33,7 +35,7 @@ import { ExternalLink, FileCode, Image as ImageMark, Paperclip, PanelRightClose,
|
|
|
33
35
|
import { Box, DropdownMenu, DropdownMenuContent, DropdownMenuCheckboxItem, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, Text, View, XStack, YStack, } from '@hanzo/ui';
|
|
34
36
|
import { Card, Quiet, Row } from './card.js';
|
|
35
37
|
import { useOpen } from './open.js';
|
|
36
|
-
import { address, channel,
|
|
38
|
+
import { address, channel, name as titleOf, openTab, pickTab, pin, shutTab, take, usePane, } from './pane.js';
|
|
37
39
|
import { useProviders, useSkills } from './tools.js';
|
|
38
40
|
import { activate, startDevice } from './connect.js';
|
|
39
41
|
/** How many rows a card shows before it offers the rest. */
|
|
@@ -115,7 +117,11 @@ function outputs(said) {
|
|
|
115
117
|
});
|
|
116
118
|
return out;
|
|
117
119
|
}
|
|
118
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* What went into this conversation: pictures asked about, files sent fenced
|
|
122
|
+
* under their names (`compose` in `lib/attach`), and files held for the next
|
|
123
|
+
* message.
|
|
124
|
+
*/
|
|
119
125
|
function sources(said, held) {
|
|
120
126
|
const out = [];
|
|
121
127
|
said.forEach((m, turn) => {
|
|
@@ -124,15 +130,17 @@ function sources(said, held) {
|
|
|
124
130
|
pictures(m).forEach((href, i) => {
|
|
125
131
|
out.push({ id: `s${turn}.${i}`, name: 'Attached image', kind: 'image', href });
|
|
126
132
|
});
|
|
133
|
+
let n = 0;
|
|
134
|
+
for (const [, info, body] of words(m).matchAll(FENCE)) {
|
|
135
|
+
n += 1;
|
|
136
|
+
const named = info.trim();
|
|
137
|
+
if (!suffix(named))
|
|
138
|
+
continue;
|
|
139
|
+
out.push({ id: `sf${turn}.${n}`, name: named, kind: 'file', body, mime: MIME[suffix(named)] ?? 'text/plain' });
|
|
140
|
+
}
|
|
127
141
|
});
|
|
128
142
|
for (const h of held) {
|
|
129
|
-
out.push({
|
|
130
|
-
id: h.id,
|
|
131
|
-
name: h.name,
|
|
132
|
-
kind: h.type.startsWith('image/') ? 'image' : 'file',
|
|
133
|
-
href: h.href,
|
|
134
|
-
mime: h.type,
|
|
135
|
-
});
|
|
143
|
+
out.push({ id: h.id, name: h.name, kind: h.kind === 'image' ? 'image' : 'file', href: h.href, mime: h.type });
|
|
136
144
|
}
|
|
137
145
|
return out;
|
|
138
146
|
}
|
|
@@ -238,23 +246,6 @@ const Note = ({ children }) => (_jsx(DropdownMenuLabel, { children: _jsx(Text, {
|
|
|
238
246
|
function Add({ at }) {
|
|
239
247
|
return (_jsxs(DropdownMenu, { placement: "bottom-end", maxHeight: 420, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Box, { render: "button", "aria-label": "Add to this conversation", p: "$1", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(Plus, { size: 16, "aria-hidden": true }) }) }), _jsxs(DropdownMenuContent, { align: "end", children: [_jsx(DropdownMenuItem, { onClick: () => take(at), children: _jsx(Word, { children: "Attach files" }) }), _jsx(DropdownMenuSeparator, {}), _jsx(Skills, {}), _jsx(DropdownMenuSeparator, {}), _jsx(Services, {})] })] }));
|
|
240
248
|
}
|
|
241
|
-
/**
|
|
242
|
-
* Asks for files and takes what is given.
|
|
243
|
-
*
|
|
244
|
-
* The picker is the browser's, minted and dropped per press rather than kept as
|
|
245
|
-
* a hidden input in the markup: a menu row is not a form control, and an
|
|
246
|
-
* `<input type=file>` parked in the tree is one more thing for a screen reader
|
|
247
|
-
* to walk past.
|
|
248
|
-
*/
|
|
249
|
-
function take(at) {
|
|
250
|
-
if (typeof document === 'undefined')
|
|
251
|
-
return;
|
|
252
|
-
const ask = document.createElement('input');
|
|
253
|
-
ask.type = 'file';
|
|
254
|
-
ask.multiple = true;
|
|
255
|
-
ask.onchange = () => hold(at, Array.from(ask.files ?? []));
|
|
256
|
-
ask.click();
|
|
257
|
-
}
|
|
258
249
|
/**
|
|
259
250
|
* What this org's agents can reach for, as a section of this menu.
|
|
260
251
|
*
|
package/dist/lib/attach.d.ts
CHANGED
|
@@ -5,34 +5,39 @@
|
|
|
5
5
|
* model reads, and nothing else rides that wire, so a file goes one of two
|
|
6
6
|
* ways: an image as a `data:` URI beside the words, a text file as its own
|
|
7
7
|
* words after the message, fenced under its name. Any other kind is refused by
|
|
8
|
-
* name, before anything is
|
|
8
|
+
* name, before anything is held.
|
|
9
|
+
*
|
|
10
|
+
* This module judges and reads a file; `pane.ts` holds what it admits, per
|
|
11
|
+
* conversation, for the composer's chips and the column's Sources alike.
|
|
9
12
|
*/
|
|
10
13
|
import type { MessagePart } from '@hanzo/ui/chat';
|
|
11
|
-
/**
|
|
12
|
-
export interface
|
|
13
|
-
id: string;
|
|
14
|
-
name: string;
|
|
14
|
+
/** What a file contributes to a message once admitted. */
|
|
15
|
+
export interface Admitted {
|
|
15
16
|
kind: 'image' | 'text';
|
|
16
17
|
/** A `data:` URI for an image, the words for a text file. */
|
|
17
18
|
data: string;
|
|
18
|
-
size: number;
|
|
19
19
|
}
|
|
20
|
+
/** A file as it goes with a message. */
|
|
21
|
+
export type Going = Admitted & {
|
|
22
|
+
name: string;
|
|
23
|
+
};
|
|
20
24
|
export declare const IMAGE_MAX: number;
|
|
21
25
|
export declare const TEXT_MAX: number;
|
|
22
26
|
/** Which way a file goes with a message, or null when it cannot go. */
|
|
23
27
|
export declare function kindOf(file: {
|
|
24
28
|
name: string;
|
|
25
29
|
type: string;
|
|
26
|
-
}):
|
|
27
|
-
/** A file read
|
|
28
|
-
export declare function
|
|
30
|
+
}): Admitted['kind'] | null;
|
|
31
|
+
/** A file read for a message, or the sentence that says why it cannot go. */
|
|
32
|
+
export declare function admit(file: File): Promise<Admitted | string>;
|
|
29
33
|
/**
|
|
30
|
-
* The message as sent: the words, then each text file fenced
|
|
31
|
-
*
|
|
32
|
-
*
|
|
34
|
+
* The message as sent: the words, then each text file fenced with its name as
|
|
35
|
+
* the info string — the shape a model writes a file in, and the one the
|
|
36
|
+
* column's Sources reads back out of the turn. With no words and no text file,
|
|
37
|
+
* the images' names, so the turn says what it carries.
|
|
33
38
|
*/
|
|
34
|
-
export declare function compose(text: string, files: readonly
|
|
39
|
+
export declare function compose(text: string, files: readonly Going[]): string;
|
|
35
40
|
/** The images of a message, as the URIs @hanzo/ai's `send` takes. */
|
|
36
|
-
export declare const images: (files: readonly
|
|
41
|
+
export declare const images: (files: readonly Going[]) => string[];
|
|
37
42
|
/** A wire turn's parts — text and `image_url` — as the parts `@hanzo/ui/chat` draws. */
|
|
38
43
|
export declare function partsOf(content: readonly unknown[]): MessagePart[];
|
package/dist/lib/attach.js
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
* model reads, and nothing else rides that wire, so a file goes one of two
|
|
6
6
|
* ways: an image as a `data:` URI beside the words, a text file as its own
|
|
7
7
|
* words after the message, fenced under its name. Any other kind is refused by
|
|
8
|
-
* name, before anything is
|
|
8
|
+
* name, before anything is held.
|
|
9
|
+
*
|
|
10
|
+
* This module judges and reads a file; `pane.ts` holds what it admits, per
|
|
11
|
+
* conversation, for the composer's chips and the column's Sources alike.
|
|
9
12
|
*/
|
|
10
13
|
export const IMAGE_MAX = 8 * 1024 * 1024;
|
|
11
14
|
export const TEXT_MAX = 256 * 1024;
|
|
@@ -30,8 +33,8 @@ function read(file, as) {
|
|
|
30
33
|
reader.readAsText(file);
|
|
31
34
|
});
|
|
32
35
|
}
|
|
33
|
-
/** A file read
|
|
34
|
-
export async function
|
|
36
|
+
/** A file read for a message, or the sentence that says why it cannot go. */
|
|
37
|
+
export async function admit(file) {
|
|
35
38
|
const kind = kindOf(file);
|
|
36
39
|
if (!kind)
|
|
37
40
|
return `${file.name}: only images and text files go with a message.`;
|
|
@@ -39,8 +42,7 @@ export async function hold(file) {
|
|
|
39
42
|
if (file.size > max)
|
|
40
43
|
return `${file.name} is larger than ${kind === 'image' ? '8 MB' : '256 KB'}.`;
|
|
41
44
|
try {
|
|
42
|
-
|
|
43
|
-
return { id: crypto.randomUUID(), name: file.name, kind, data, size: file.size };
|
|
45
|
+
return { kind, data: await read(file, kind === 'image' ? 'url' : 'text') };
|
|
44
46
|
}
|
|
45
47
|
catch {
|
|
46
48
|
return `${file.name} could not be read.`;
|
|
@@ -52,16 +54,17 @@ function fence(words) {
|
|
|
52
54
|
return '`'.repeat(Math.max(3, longest + 1));
|
|
53
55
|
}
|
|
54
56
|
/**
|
|
55
|
-
* The message as sent: the words, then each text file fenced
|
|
56
|
-
*
|
|
57
|
-
*
|
|
57
|
+
* The message as sent: the words, then each text file fenced with its name as
|
|
58
|
+
* the info string — the shape a model writes a file in, and the one the
|
|
59
|
+
* column's Sources reads back out of the turn. With no words and no text file,
|
|
60
|
+
* the images' names, so the turn says what it carries.
|
|
58
61
|
*/
|
|
59
62
|
export function compose(text, files) {
|
|
60
63
|
const blocks = files
|
|
61
64
|
.filter((f) => f.kind === 'text')
|
|
62
65
|
.map((f) => {
|
|
63
66
|
const f3 = fence(f.data);
|
|
64
|
-
return `${f.name}\n${
|
|
67
|
+
return `${f3}${f.name}\n${f.data.replace(/\n$/, '')}\n${f3}`;
|
|
65
68
|
});
|
|
66
69
|
const out = [text.trim(), ...blocks].filter(Boolean).join('\n\n');
|
|
67
70
|
return out || files.map((f) => f.name).join(', ');
|
package/dist/open.d.ts
CHANGED
|
@@ -75,7 +75,7 @@ export declare function withAgents(names: string[]): void;
|
|
|
75
75
|
export declare function showAside(pane: string | null): void;
|
|
76
76
|
/** Asks the shell to open Settings on one pane, or closes it with null. */
|
|
77
77
|
export declare function showSettings(pane: string | null): void;
|
|
78
|
-
/** Empties the room: no thread, and a fresh count so the pane
|
|
79
|
-
* when there was
|
|
78
|
+
/** Empties the room: no thread, nobody seated, and a fresh count so the pane
|
|
79
|
+
* hears it even when there was nothing to leave. "New chat" is this. */
|
|
80
80
|
export declare function empty(): void;
|
|
81
81
|
export declare function useOpen(): Open;
|
package/dist/open.js
CHANGED
|
@@ -118,10 +118,10 @@ export function showAside(pane) {
|
|
|
118
118
|
export function showSettings(pane) {
|
|
119
119
|
set({ ...selection, settings: pane });
|
|
120
120
|
}
|
|
121
|
-
/** Empties the room: no thread, and a fresh count so the pane
|
|
122
|
-
* when there was
|
|
121
|
+
/** Empties the room: no thread, nobody seated, and a fresh count so the pane
|
|
122
|
+
* hears it even when there was nothing to leave. "New chat" is this. */
|
|
123
123
|
export function empty() {
|
|
124
|
-
set({ ...selection, thread: null, emptied: selection.emptied + 1 });
|
|
124
|
+
set({ ...selection, thread: null, agents: [], emptied: selection.emptied + 1 });
|
|
125
125
|
}
|
|
126
126
|
export function useOpen() {
|
|
127
127
|
const current = useSyncExternalStore(subscribe, read, readOnServer);
|
package/dist/pane.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Admitted } from './lib/attach.js';
|
|
1
2
|
/** One open tab in the pane's browser. */
|
|
2
3
|
export interface Leaf {
|
|
3
4
|
id: string;
|
|
@@ -7,13 +8,14 @@ export interface Leaf {
|
|
|
7
8
|
title: string;
|
|
8
9
|
}
|
|
9
10
|
/**
|
|
10
|
-
* A file the reader put into this conversation from their own disk
|
|
11
|
+
* A file the reader put into this conversation from their own disk, read and
|
|
12
|
+
* waiting to go with the next message.
|
|
11
13
|
*
|
|
12
14
|
* `href` is an object URL and is the reason this is never written down — see
|
|
13
15
|
* the note at the top. Everything else is read off the `File` itself, so a name
|
|
14
16
|
* and a size here are the file system's, never a guess.
|
|
15
17
|
*/
|
|
16
|
-
export interface Held {
|
|
18
|
+
export interface Held extends Admitted {
|
|
17
19
|
id: string;
|
|
18
20
|
name: string;
|
|
19
21
|
size: number;
|
|
@@ -29,6 +31,8 @@ export interface Pane {
|
|
|
29
31
|
/** Whether the summary column stands beside the browser. */
|
|
30
32
|
pinned: boolean;
|
|
31
33
|
held: Held[];
|
|
34
|
+
/** Why the last files offered were refused, in the reader's words, or null. */
|
|
35
|
+
refused: string | null;
|
|
32
36
|
}
|
|
33
37
|
/**
|
|
34
38
|
* The conversation a pane belongs to.
|
|
@@ -83,8 +87,31 @@ export declare function pickTab(key: string, id: string): void;
|
|
|
83
87
|
export declare function shutTab(key: string, id: string): void;
|
|
84
88
|
/** Shows or hides the summary column beside this conversation's browser. */
|
|
85
89
|
export declare function pin(key: string, on: boolean): void;
|
|
86
|
-
/**
|
|
87
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Takes files from the reader's disk into this conversation.
|
|
92
|
+
*
|
|
93
|
+
* Each is judged and read by `admit`; what is refused is said by name in
|
|
94
|
+
* `refused` and nothing of it is held, so a chip never stands for a file that
|
|
95
|
+
* cannot go.
|
|
96
|
+
*/
|
|
97
|
+
export declare function hold(key: string, files: readonly File[]): Promise<void>;
|
|
98
|
+
/**
|
|
99
|
+
* Asks for files and holds what is given.
|
|
100
|
+
*
|
|
101
|
+
* The picker is the browser's, minted and dropped per press rather than kept as
|
|
102
|
+
* a hidden input in the markup: a menu row is not a form control, and an
|
|
103
|
+
* `<input type=file>` parked in the tree is one more thing for a screen reader
|
|
104
|
+
* to walk past.
|
|
105
|
+
*/
|
|
106
|
+
export declare function take(key: string): void;
|
|
107
|
+
/**
|
|
108
|
+
* Hands over everything held to go with a message, and lets go of it.
|
|
109
|
+
*
|
|
110
|
+
* The turn carries each file from here — an image as its `data:` URI, a text
|
|
111
|
+
* file as its words. The object URLs are left standing, so a tab already open
|
|
112
|
+
* on one keeps showing it.
|
|
113
|
+
*/
|
|
114
|
+
export declare function spend(key: string): Held[];
|
|
88
115
|
/** Lets go of a file, and of the handle the browser minted for it. */
|
|
89
116
|
export declare function drop(key: string, id: string): void;
|
|
90
117
|
/** This conversation's pane, and it alone — a change to another redraws nothing
|
package/dist/pane.js
CHANGED
|
@@ -20,10 +20,17 @@
|
|
|
20
20
|
// with the document that minted it, so a persisted row would come back as a
|
|
21
21
|
// name pointing at nothing. A row that looks like a file and opens nothing is
|
|
22
22
|
// worse than no row, so they are held for the session and no longer.
|
|
23
|
+
//
|
|
24
|
+
// ONE WAY IN FOR A FILE. The composer's paperclip, a drop on the room, a paste
|
|
25
|
+
// into the field and the column's `+` all call `hold`, so a file is judged by
|
|
26
|
+
// one rule (`lib/attach`), shows as a chip over the field and a row under
|
|
27
|
+
// Sources at once, and goes with the next message. Sending spends what is
|
|
28
|
+
// held; from then on the turn itself is the record Sources reads.
|
|
23
29
|
import { useSyncExternalStore } from 'react';
|
|
30
|
+
import { admit } from './lib/attach.js';
|
|
24
31
|
/** A pane nobody has touched. Shared, and never mutated — see `EMPTY` in
|
|
25
32
|
* `open.ts` for why a fresh object per read is an infinite render. */
|
|
26
|
-
const BARE = { tabs: [], at: null, pinned: true, held: [] };
|
|
33
|
+
const BARE = { tabs: [], at: null, pinned: true, held: [], refused: null };
|
|
27
34
|
const where = (key) => `hanzo.pane.${key}`;
|
|
28
35
|
/**
|
|
29
36
|
* The conversation a pane belongs to.
|
|
@@ -90,6 +97,7 @@ function read(key) {
|
|
|
90
97
|
at: tabs.some((t) => t.id === kept.at) ? kept.at : (tabs[0]?.id ?? null),
|
|
91
98
|
pinned: kept.pinned !== false,
|
|
92
99
|
held: [],
|
|
100
|
+
refused: null,
|
|
93
101
|
};
|
|
94
102
|
}
|
|
95
103
|
}
|
|
@@ -201,18 +209,58 @@ export function shutTab(key, id) {
|
|
|
201
209
|
export function pin(key, on) {
|
|
202
210
|
edit(key, (was) => (was.pinned === on ? was : { ...was, pinned: on }));
|
|
203
211
|
}
|
|
204
|
-
/**
|
|
205
|
-
|
|
212
|
+
/**
|
|
213
|
+
* Takes files from the reader's disk into this conversation.
|
|
214
|
+
*
|
|
215
|
+
* Each is judged and read by `admit`; what is refused is said by name in
|
|
216
|
+
* `refused` and nothing of it is held, so a chip never stands for a file that
|
|
217
|
+
* cannot go.
|
|
218
|
+
*/
|
|
219
|
+
export async function hold(key, files) {
|
|
206
220
|
if (!files.length)
|
|
207
221
|
return;
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
222
|
+
const judged = await Promise.all(files.map(admit));
|
|
223
|
+
const taken = [];
|
|
224
|
+
const refused = [];
|
|
225
|
+
judged.forEach((one, i) => {
|
|
226
|
+
const file = files[i];
|
|
227
|
+
if (typeof one === 'string')
|
|
228
|
+
refused.push(one);
|
|
229
|
+
else
|
|
230
|
+
taken.push({ id: mint('h'), name: file.name, size: file.size, type: file.type, href: URL.createObjectURL(file), ...one });
|
|
231
|
+
});
|
|
232
|
+
edit(key, (was) => ({ ...was, held: [...was.held, ...taken], refused: refused.length ? refused.join(' ') : null }));
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Asks for files and holds what is given.
|
|
236
|
+
*
|
|
237
|
+
* The picker is the browser's, minted and dropped per press rather than kept as
|
|
238
|
+
* a hidden input in the markup: a menu row is not a form control, and an
|
|
239
|
+
* `<input type=file>` parked in the tree is one more thing for a screen reader
|
|
240
|
+
* to walk past.
|
|
241
|
+
*/
|
|
242
|
+
export function take(key) {
|
|
243
|
+
if (typeof document === 'undefined')
|
|
244
|
+
return;
|
|
245
|
+
const ask = document.createElement('input');
|
|
246
|
+
ask.type = 'file';
|
|
247
|
+
ask.multiple = true;
|
|
248
|
+
ask.onchange = () => void hold(key, Array.from(ask.files ?? []));
|
|
249
|
+
ask.click();
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Hands over everything held to go with a message, and lets go of it.
|
|
253
|
+
*
|
|
254
|
+
* The turn carries each file from here — an image as its `data:` URI, a text
|
|
255
|
+
* file as its words. The object URLs are left standing, so a tab already open
|
|
256
|
+
* on one keeps showing it.
|
|
257
|
+
*/
|
|
258
|
+
export function spend(key) {
|
|
259
|
+
const was = read(key);
|
|
260
|
+
if (!was.held.length && !was.refused)
|
|
261
|
+
return [];
|
|
262
|
+
write(key, { ...was, held: [], refused: null });
|
|
263
|
+
return was.held;
|
|
216
264
|
}
|
|
217
265
|
/** Lets go of a file, and of the handle the browser minted for it. */
|
|
218
266
|
export function drop(key, id) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/rooms",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "The workspace rooms — Home, Chat, Inbox, Contacts, Meet, Cal, Drive, Board, Work, Bots — and the frame that holds them, once, for every Hanzo surface.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|