@hanzo/rooms 0.1.11 → 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/Drive.js CHANGED
@@ -1,26 +1,24 @@
1
1
  'use client';
2
2
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
- import { ArrowRight, ChevronDown, ChevronRight, Clock, Cloud, Download, File as FileIcon, FileArchive, FileAudio, FileCode, FileImage, FileText, FileVideo, Folder, FolderInput, FolderPlus, HardDrive, LayoutGrid, List, MoreHorizontal, PanelLeft, Plus, Search, Trash2, Upload, } from 'lucide-react';
4
+ import { ArrowRight, ChevronDown, ChevronRight, Clock, Cloud, Download, File as FileIcon, FileArchive, FileAudio, FileCode, FileImage, FileText, FileVideo, Folder, FolderInput, FolderPlus, HardDrive, ListTree, MessageSquare, LayoutGrid, List, MoreHorizontal, PanelLeft, Plus, Search, Trash2, Upload, } from 'lucide-react';
5
5
  import { Box, Button, Text, View, XStack, YStack } from '@hanzo/ui';
6
6
  import { useIam, useOrganizations } from '@hanzo/iam/react';
7
7
  import { useAi } from './lib/ai.js';
8
- import { useOpen } from './open.js';
8
+ import { empty, useOpen } from './open.js';
9
+ import { attach, channel } from './pane.js';
10
+ import { useRooms } from './host.js';
11
+ import { Badge, Contents, Hits } from './Contents.js';
12
+ import { weigh as size } from './lib/attach.js';
13
+ import { dropped, filesIn, forget, pool, register, search, settled, slug, upload, } from './lib/files.js';
9
14
  import { checkoutUrl } from './lib/pay.js';
10
15
  import { BAD, mix } from './lib/mix.js';
11
16
  const blame = (e) => {
12
17
  const text = e instanceof Error ? e.message : String(e ?? '');
13
18
  return text || 'The store did not say what went wrong.';
14
19
  };
15
- /** Format byte count nicely */
16
- const weigh = (bytes) => {
17
- if (!bytes)
18
- return '—';
19
- const units = ['B', 'KB', 'MB', 'GB', 'TB'];
20
- const step = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
21
- const size = bytes / 1024 ** step;
22
- return `${size < 10 && step > 0 ? size.toFixed(1) : Math.round(size)} ${units[step]}`;
23
- };
20
+ /** A size as a row shows it; nothing is a dash. */
21
+ const weigh = (bytes) => (bytes ? size(bytes) : '—');
24
22
  /** Format timestamp */
25
23
  const when = (stamp) => {
26
24
  if (!stamp)
@@ -36,11 +34,6 @@ const when = (stamp) => {
36
34
  };
37
35
  /** The store publishes a bucket's own display name; the slug is the fallback. */
38
36
  const getBucketDisplayName = (b) => (typeof b === 'string' ? b : b?.displayName || b?.name) || 'Drive';
39
- const bucketName = (workspace) => workspace
40
- .toLowerCase()
41
- .replace(/[^a-z0-9]+/g, '-')
42
- .replace(/^-+|-+$/g, '')
43
- .slice(0, 40) || 'hanzo';
44
37
  const wire = (key) => key.split('/').map(encodeURIComponent).join('/');
45
38
  const objects = (bucket) => `/v1/s3/buckets/${encodeURIComponent(bucket)}/objects`;
46
39
  async function putBytes(client, bucket, key, body, type) {
@@ -121,9 +114,13 @@ export function Drive() {
121
114
  const { client } = useAi();
122
115
  const { user, isAuthenticated } = useIam();
123
116
  const ready = Boolean(client && isAuthenticated);
124
- const { bucket, openBucket } = useOpen();
117
+ const { bucket, openBucket, room } = useOpen();
118
+ const { router } = useRooms();
125
119
  const { currentOrg } = useOrganizations();
126
120
  const workspace = currentOrg?.displayName || currentOrg?.name || 'hanzo';
121
+ // THE WORKSPACE'S BUCKET is the one named for the org: where a file put into a
122
+ // chat lands (lib/files `workspace`), so it is where Drive opens.
123
+ const home = currentOrg?.name ? slug(currentOrg.name) : null;
127
124
  const [navSection, setNavSection] = useState('my-drive');
128
125
  const [viewMode, setViewMode] = useState('list');
129
126
  const [searchQuery, setSearchQuery] = useState('');
@@ -143,6 +140,12 @@ export function Drive() {
143
140
  // What the drive actually holds, measured. There is no quota route, so there
144
141
  // is no quota drawn: a number nobody publishes would be a number invented.
145
142
  const [usage, setUsage] = useState(null);
143
+ // WHAT THE INDEX SAYS of each object in the open bucket, by key: queued,
144
+ // indexing (and which stage), ready, kept-not-indexed or failed.
145
+ const [index, setIndex] = useState(() => new Map());
146
+ // A search's passages, and the file whose contents are open beside the list.
147
+ const [hits, setHits] = useState(null);
148
+ const [contents, setContents] = useState(null);
146
149
  const fileInputRef = useRef(null);
147
150
  const folderInputRef = useRef(null);
148
151
  const dragCounter = useRef(0);
@@ -180,15 +183,80 @@ export function Drive() {
180
183
  // THE DRIVE IS A BUCKET THE ORG OWNS. It used to fall back to the literal
181
184
  // "my-drive", which is a nav label rather than a name the store answers to —
182
185
  // so every read 404'd and every write went nowhere.
183
- const activeBucket = bucket || buckets?.[0]?.name || null;
186
+ const landing = (home && buckets?.some((b) => b.name === home) ? home : buckets?.[0]?.name) || null;
187
+ const activeBucket = bucket || landing;
184
188
  // YOU LAND IN THE DRIVE, not in a chooser. The root view lists buckets and
185
189
  // projects, and the toolbar's verbs all act on an OPEN bucket — so opening
186
190
  // the org's first one is what makes New, upload and the listing agree about
187
191
  // where they are. It is also what a drive does: the files, on arrival.
188
192
  useEffect(() => {
189
- if (!bucket && buckets && buckets.length > 0)
190
- openBucket(buckets[0].name);
191
- }, [bucket, buckets, openBucket]);
193
+ if (!bucket && landing)
194
+ openBucket(landing);
195
+ }, [bucket, landing, openBucket]);
196
+ // THE INDEX'S WORD on this bucket's files, read again every few seconds while
197
+ // any of them is still moving through its stages.
198
+ useEffect(() => {
199
+ if (!ready || !client || !activeBucket) {
200
+ setIndex(new Map());
201
+ return;
202
+ }
203
+ let live = true;
204
+ let clock;
205
+ const pass = () => filesIn(client, activeBucket)
206
+ .then((list) => {
207
+ if (!live)
208
+ return;
209
+ setIndex(new Map(list.map((f) => [f.key, f])));
210
+ if (list.some((f) => !settled(f)))
211
+ clock = setTimeout(pass, 4000);
212
+ })
213
+ .catch(() => { });
214
+ void pass();
215
+ return () => {
216
+ live = false;
217
+ if (clock)
218
+ clearTimeout(clock);
219
+ };
220
+ }, [ready, client, activeBucket, reload]);
221
+ // SEARCH READS THE FILES, not just their names: three characters or more ask
222
+ // the index for the passages that match, beside the name filter.
223
+ useEffect(() => {
224
+ const q = searchQuery.trim();
225
+ if (!ready || !client || q.length < 3) {
226
+ setHits(null);
227
+ return;
228
+ }
229
+ let live = true;
230
+ const clock = setTimeout(() => {
231
+ search(client, q, 12)
232
+ .then((out) => live && setHits(out.passages ?? []))
233
+ .catch(() => live && setHits([]));
234
+ }, 400);
235
+ return () => {
236
+ live = false;
237
+ clearTimeout(clock);
238
+ };
239
+ }, [ready, client, searchQuery]);
240
+ /** Opens a file's contents beside the list, at a section when one is named. */
241
+ const showContents = useCallback((id, at) => {
242
+ const known = [...index.values()].find((f) => f.id === id);
243
+ if (known)
244
+ return setContents({ file: known, at });
245
+ if (!client)
246
+ return;
247
+ void client.http
248
+ .json({ path: `/v1/knowledge/files/${encodeURIComponent(id)}` })
249
+ .then((f) => setContents({ file: f, at }))
250
+ .catch((e) => setFailed(blame(e)));
251
+ }, [index, client]);
252
+ /** ASK IN CHAT: a new conversation with the file put into it, by reference. */
253
+ const askInChat = useCallback((f) => {
254
+ if (!client)
255
+ return;
256
+ empty();
257
+ attach(channel({ room, thread: null, agent: null }), [f], { api: client, org: currentOrg?.name ?? null });
258
+ router.push('/chat');
259
+ }, [client, room, currentOrg, router]);
192
260
  // WHAT IS AT THIS LEVEL. The store's listing takes `recursive`; passing
193
261
  // `delimiter` (S3's own word) is silently ignored, so a flat listing came
194
262
  // back and every folder read as a file.
@@ -252,21 +320,30 @@ export function Drive() {
252
320
  setBusy(null);
253
321
  }
254
322
  }, [client, activeBucket, busy]);
255
- // UPLOAD is two calls and the bytes skip the platform: mint a presigned PUT,
256
- // then send the file straight to the store. A PUT at the object's own address
257
- // is not a route the platform has — that address mints URLs and deletes.
323
+ // UPLOAD skips the platform: the bytes go straight to the store through
324
+ // presigned PUTs — one for a small file, one per 16 MB part for a large one,
325
+ // resumed from the parts the store holds when a connection drops — several
326
+ // files at once. Each lands as a workspace file: registered with the index,
327
+ // which reads it into contents, passages and links.
258
328
  const handleUpload = useCallback((files) => {
259
329
  if (!files)
260
330
  return;
261
331
  const list = Array.from(files);
262
332
  if (!list.length || !client || !activeBucket)
263
333
  return;
264
- void run(`Uploading ${list[0].name}`, async (say) => {
265
- for (const file of list) {
266
- say(`Uploading ${file.name}`);
267
- const name = file.webkitRelativePath || file.name;
268
- await putBytes(client, activeBucket, `${prefix}${name}`, file, file.type || undefined);
269
- }
334
+ void run(`Uploading ${list.length === 1 ? list[0].name : `${list.length} files`}`, async (say) => {
335
+ const sent = new Map();
336
+ const total = list.reduce((n, f) => n + f.size, 0) || 1;
337
+ const tell = () => say(`Uploading ${list.length === 1 ? list[0].name : `${list.length} files`} — ${Math.floor(([...sent.values()].reduce((a, b) => a + b, 0) / total) * 100)}%`);
338
+ await pool(list, 3, async (file) => {
339
+ const f = file;
340
+ const key = `${prefix}${f.relative || f.webkitRelativePath || file.name}`;
341
+ await upload(client, activeBucket, key, file, (fraction) => {
342
+ sent.set(file, fraction * file.size);
343
+ tell();
344
+ });
345
+ await register(client, activeBucket, key);
346
+ });
270
347
  });
271
348
  }, [client, activeBucket, prefix, run]);
272
349
  const handleCreateFolder = useCallback((folderName) => {
@@ -296,18 +373,27 @@ export function Drive() {
296
373
  if (!client || !activeBucket)
297
374
  return;
298
375
  const key = `${prefix}${entry.key}`;
376
+ // A deleted file leaves the index too, so search and chat stop citing it.
377
+ const unindex = async (k) => {
378
+ const f = index.get(k);
379
+ if (f)
380
+ await forget(client, f.id).catch(() => { });
381
+ };
299
382
  void run(`Deleting ${entry.key}`, async (say) => {
300
- if (!entry.isDir)
301
- return remove(client, activeBucket, key);
383
+ if (!entry.isDir) {
384
+ await remove(client, activeBucket, key);
385
+ return unindex(key);
386
+ }
302
387
  const root = key.endsWith('/') ? key : `${key}/`;
303
388
  const out = await client.http.json({ path: objects(activeBucket), query: { prefix: root, recursive: 'true' } });
304
389
  for (const e of (out?.objects ?? []).filter((x) => !x.isDir)) {
305
390
  say(`Deleting ${e.key}`);
306
391
  await remove(client, activeBucket, root + e.key);
392
+ await unindex(root + e.key);
307
393
  }
308
394
  await remove(client, activeBucket, root).catch(() => { });
309
395
  });
310
- }, [client, activeBucket, prefix, run]);
396
+ }, [client, activeBucket, prefix, run, index]);
311
397
  // DOWNLOAD follows a presigned GET. The object's platform address answers a
312
398
  // signed URL, not the bytes, and it needs the caller's token — which an
313
399
  // anchor href does not carry.
@@ -380,8 +466,8 @@ export function Drive() {
380
466
  e.stopPropagation();
381
467
  dragCounter.current = 0;
382
468
  setOver(false);
383
- if (e.dataTransfer.files)
384
- handleUpload(e.dataTransfer.files);
469
+ if (e.dataTransfer.files?.length)
470
+ void dropped(e.dataTransfer).then(handleUpload);
385
471
  }, children: [_jsx("input", { ref: fileInputRef, type: "file", multiple: true, hidden: true, onChange: (e) => {
386
472
  handleUpload(e.target.files);
387
473
  e.currentTarget.value = '';
@@ -435,7 +521,7 @@ export function Drive() {
435
521
  setNaming(null);
436
522
  if (e.key === 'Enter')
437
523
  handleCreateFolder(naming.value);
438
- }, placeholder: "Folder name", "aria-label": "Folder name" }), ...BARE }), _jsx(Button, { variant: "primary", size: "sm", onClick: () => handleCreateFolder(naming.value), children: "Create" }), _jsx(Button, { variant: "ghost", size: "sm", color: "$soft", onClick: () => setNaming(null), children: "Cancel" })] })) : null, busy ? (_jsxs(Text, { fontSize: "$2", color: "$soft", children: [busy, "\u2026"] })) : null, bucket || prefix ? (_jsx(YStack, { gap: "$2", children: _jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: 8, overflowX: "auto", overflowY: "hidden", children: [_jsxs(View, { display: "grid", gridTemplateColumns: FILES, minW: "min-content", px: 16, py: 10, borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsx(Text, { ...HEAD, children: "NAME" }), _jsx(Text, { ...HEAD, children: "TYPE" }), _jsx(Text, { ...HEAD, children: "UPDATED \u2193" }), _jsx(Text, { ...HEAD, children: "SIZE" }), _jsx(View, {})] }), !filteredEntries || filteredEntries.length === 0 ? (_jsx(Text, { py: 36, px: 16, text: "center", fontSize: "$2", color: "$faint", children: "None" })) : (filteredEntries.map((e) => {
524
+ }, placeholder: "Folder name", "aria-label": "Folder name" }), ...BARE }), _jsx(Button, { variant: "primary", size: "sm", onClick: () => handleCreateFolder(naming.value), children: "Create" }), _jsx(Button, { variant: "ghost", size: "sm", color: "$soft", onClick: () => setNaming(null), children: "Cancel" })] })) : null, busy ? (_jsxs(Text, { fontSize: "$2", color: "$soft", "data-slot": "drive-busy", children: [busy, "\u2026"] })) : null, hits && hits.length ? _jsx(Hits, { hits: hits, onOpen: (p) => showContents(p.file.id, p.section.id) }) : null, bucket || prefix ? (_jsx(YStack, { gap: "$2", children: _jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: 8, overflowX: "auto", overflowY: "hidden", children: [_jsxs(View, { display: "grid", gridTemplateColumns: FILES, minW: "min-content", px: 16, py: 10, borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsx(Text, { ...HEAD, children: "NAME" }), _jsx(Text, { ...HEAD, children: "TYPE" }), _jsx(Text, { ...HEAD, children: "UPDATED \u2193" }), _jsx(Text, { ...HEAD, children: "SIZE" }), _jsx(View, {})] }), !filteredEntries || filteredEntries.length === 0 ? (_jsx(Text, { py: 36, px: 16, text: "center", fontSize: "$2", color: "$faint", children: "None" })) : (filteredEntries.map((e) => {
439
525
  const isFolder = e.isDir || e.key.endsWith('/');
440
526
  const cleanName = e.key.replace(/\/+$/, '');
441
527
  return (_jsxs(View, { display: "grid", gridTemplateColumns: FILES, minW: "min-content", px: 16, py: 11, items: "center", cursor: "pointer", borderBottomWidth: 1, borderColor: "$borderColor", transition: "100ms", hoverStyle: { bg: '$hover' }, onClick: () => {
@@ -445,10 +531,16 @@ export function Drive() {
445
531
  else {
446
532
  handleDownload(e);
447
533
  }
448
- }, children: [_jsxs(XStack, { items: "center", gap: "$2.5", minW: 0, children: [_jsx(Glyph, { entry: e, size: 16 }), _jsx(Text, { ...NAME, children: cleanName })] }), _jsx(View, { items: "flex-start", children: _jsx(Text, { ...CHIP, children: isFolder ? 'Folder' : cleanName.split('.').pop()?.toUpperCase() || 'File' }) }), _jsx(Text, { ...CELL, children: when(e.lastModified) }), _jsx(Text, { ...CELL, children: weigh(e.size) }), _jsxs(View, { position: "relative", items: "flex-start", children: [_jsx(More, { onPress: (ev) => {
534
+ }, children: [_jsxs(XStack, { items: "center", gap: "$2.5", minW: 0, children: [_jsx(Glyph, { entry: e, size: 16 }), _jsxs(YStack, { minW: 0, children: [_jsx(Text, { ...NAME, children: cleanName }), !isFolder && index.get(prefix + e.key) ? _jsx(Badge, { f: index.get(prefix + e.key) }) : null] })] }), _jsx(View, { items: "flex-start", children: _jsx(Text, { ...CHIP, children: isFolder ? 'Folder' : cleanName.split('.').pop()?.toUpperCase() || 'File' }) }), _jsx(Text, { ...CELL, children: when(e.lastModified) }), _jsx(Text, { ...CELL, children: weigh(e.size) }), _jsxs(View, { position: "relative", items: "flex-start", children: [_jsx(More, { onPress: (ev) => {
449
535
  ev.stopPropagation();
450
536
  setItemMenuKey(itemMenuKey === e.key ? null : e.key);
451
- } }), itemMenuKey === e.key ? (_jsxs(YStack, { position: "absolute", r: 0, t: "100%", bg: "var(--popover)", borderWidth: 1, borderColor: "$borderColor", rounded: 8, boxShadow: "var(--shadow-lg)", z: "var(--z-dropdown)", p: 4, minW: 140, onClick: (ev) => ev.stopPropagation(), children: [!isFolder ? (_jsx(Pick, { icon: Download, label: "Download", onPress: () => {
537
+ } }), itemMenuKey === e.key ? (_jsxs(YStack, { position: "absolute", r: 0, t: "100%", bg: "var(--popover)", borderWidth: 1, borderColor: "$borderColor", rounded: 8, boxShadow: "var(--shadow-lg)", z: "var(--z-dropdown)", p: 4, minW: 140, onClick: (ev) => ev.stopPropagation(), children: [!isFolder && index.get(prefix + e.key) ? (_jsxs(_Fragment, { children: [_jsx(Pick, { icon: ListTree, label: "Contents", onPress: () => {
538
+ setItemMenuKey(null);
539
+ setContents({ file: index.get(prefix + e.key) });
540
+ } }), _jsx(Pick, { icon: MessageSquare, label: "Ask in chat", onPress: () => {
541
+ setItemMenuKey(null);
542
+ askInChat(index.get(prefix + e.key));
543
+ } })] })) : null, !isFolder ? (_jsx(Pick, { icon: Download, label: "Download", onPress: () => {
452
544
  setItemMenuKey(null);
453
545
  handleDownload(e);
454
546
  } })) : null, _jsx(Pick, { icon: Trash2, label: "Delete", tone: BAD, onPress: () => {
@@ -462,5 +554,5 @@ export function Drive() {
462
554
  const targetBucket = buckets[0]?.name || 'hanzo';
463
555
  openBucket(targetBucket);
464
556
  setPrefix(`projects/${p.name || p.id}/`);
465
- }, display: "grid", gridTemplateColumns: PROJECTS, minW: "min-content", px: 16, py: 11, items: "center", cursor: "pointer", borderBottomWidth: 1, borderColor: "$borderColor", transition: "100ms", hoverStyle: { bg: '$hover' }, children: [_jsxs(XStack, { items: "center", gap: "$2.5", minW: 0, children: [_jsx(Folder, { size: 16, color: "var(--foreground)" }), _jsx(Text, { ...NAME, children: p.name })] }), _jsx(View, { items: "flex-start", children: _jsx(Text, { ...CHIP, children: p.type }) }), _jsx(Text, { ...CELL, children: p.updated }), _jsx(Text, { ...CELL, children: p.size }), _jsx(View, { items: "flex-start", children: _jsx(More, { onPress: (e) => e.stopPropagation() }) })] }, p.id))))] })] })] }))] })] }), over ? (_jsx(Box, { position: "absolute", t: 0, l: 0, r: 0, b: 0, z: 1000, bg: VEIL, borderWidth: 2, borderStyle: "dashed", borderColor: "var(--ring)", rounded: "$3", display: "flex", items: "center", justify: "center", pointerEvents: "none", children: _jsxs(YStack, { items: "center", gap: "$2", children: [_jsx(Upload, { size: 36, color: "currentColor" }), _jsxs(Text, { fontSize: "$4", fontWeight: "600", color: "$ink", children: ["Drop files to upload to ", bucket || workspace] })] }) })) : null] }));
557
+ }, display: "grid", gridTemplateColumns: PROJECTS, minW: "min-content", px: 16, py: 11, items: "center", cursor: "pointer", borderBottomWidth: 1, borderColor: "$borderColor", transition: "100ms", hoverStyle: { bg: '$hover' }, children: [_jsxs(XStack, { items: "center", gap: "$2.5", minW: 0, children: [_jsx(Folder, { size: 16, color: "var(--foreground)" }), _jsx(Text, { ...NAME, children: p.name })] }), _jsx(View, { items: "flex-start", children: _jsx(Text, { ...CHIP, children: p.type }) }), _jsx(Text, { ...CELL, children: p.updated }), _jsx(Text, { ...CELL, children: p.size }), _jsx(View, { items: "flex-start", children: _jsx(More, { onPress: (e) => e.stopPropagation() }) })] }, p.id))))] })] })] }))] })] }), contents && client ? (_jsx(Contents, { api: client, file: contents.file, at: contents.at, onClose: () => setContents(null), onAsk: askInChat, onFollow: (id, at) => showContents(id, at) })) : null, over ? (_jsx(Box, { position: "absolute", t: 0, l: 0, r: 0, b: 0, z: 1000, bg: VEIL, borderWidth: 2, borderStyle: "dashed", borderColor: "var(--ring)", rounded: "$3", display: "flex", items: "center", justify: "center", pointerEvents: "none", children: _jsxs(YStack, { items: "center", gap: "$2", children: [_jsx(Upload, { size: 36, color: "currentColor" }), _jsxs(Text, { fontSize: "$4", fontWeight: "600", color: "$ink", children: ["Drop files to upload to ", bucket || workspace] })] }) })) : null] }));
466
558
  }
@@ -1,4 +1,4 @@
1
- import type { ChatMessage } from '@hanzo/ai/react';
1
+ import { type ChatMessage } from '@hanzo/ai/react';
2
2
  /**
3
3
  * The column. `said` is the conversation it stands beside; without one it draws
4
4
  * the same shape with nothing in it, which is what a room nobody has spoken in
package/dist/RightPane.js CHANGED
@@ -33,7 +33,11 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
33
33
  import { useMemo, useState } from 'react';
34
34
  import { ExternalLink, FileCode, Image as ImageMark, Paperclip, PanelRightClose, Plus, RotateCw, X, } from 'lucide-react';
35
35
  import { Box, DropdownMenu, DropdownMenuContent, DropdownMenuCheckboxItem, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, Text, View, XStack, YStack, } from '@hanzo/ui';
36
+ import { useAi } from '@hanzo/ai/react';
36
37
  import { Card, Quiet, Row } from './card.js';
38
+ import { said as told } from './lib/attach.js';
39
+ import { download, fileOf } from './lib/files.js';
40
+ import { org } from './lib/session.js';
37
41
  import { useOpen } from './open.js';
38
42
  import { address, channel, name as titleOf, openTab, pickTab, pin, shutTab, take, usePane, } from './pane.js';
39
43
  import { useProviders, useSkills } from './tools.js';
@@ -99,8 +103,13 @@ function outputs(said) {
99
103
  said.forEach((m, turn) => {
100
104
  if (m.role !== 'assistant')
101
105
  return;
106
+ // Files a turn carried by reference: their names in the turn, their bytes in the workspace.
107
+ const read = told(words(m));
108
+ for (const f of read.carried?.attached ?? []) {
109
+ out.push({ id: `sw${turn}.${f.id}`, name: f.name, kind: f.type.startsWith('image/') ? 'image' : 'file', file: f.id, mime: f.type });
110
+ }
102
111
  let n = 0;
103
- for (const [, info, body] of words(m).matchAll(FENCE)) {
112
+ for (const [, info, body] of read.text.matchAll(FENCE)) {
104
113
  n += 1;
105
114
  const named = fenceName(info, n);
106
115
  out.push({
@@ -140,7 +149,7 @@ function sources(said, held) {
140
149
  }
141
150
  });
142
151
  for (const h of held) {
143
- out.push({ id: h.id, name: h.name, kind: h.kind === 'image' ? 'image' : 'file', href: h.href, mime: h.type });
152
+ out.push({ id: h.id, name: h.name, kind: h.type.startsWith('image/') ? 'image' : 'file', href: h.href || undefined, file: h.file?.id, mime: h.type });
144
153
  }
145
154
  return out;
146
155
  }
@@ -183,12 +192,13 @@ function Summary({ at, said, held }) {
183
192
  * thirty is the kind of furniture this repo has had to delete twice.
184
193
  */
185
194
  function Marks({ at, kind, title, icon, marks, children, }) {
195
+ const ai = useAi();
186
196
  const [all, setAll] = useState(false);
187
197
  const shown = all ? marks : marks.slice(0, FEW);
188
198
  const rest = marks.length - shown.length;
189
199
  return (_jsxs(Card, { kind: kind, icon: icon, title: title, action: _jsx(Add, { at: at }), children: [marks.length === 0 ? _jsx(Quiet, { children: children }) : null, shown.map((m) => {
190
200
  const Mark = GLYPH[m.kind];
191
- return (_jsx(Row, { icon: _jsx(Mark, { size: 16, "aria-hidden": true }), onPress: () => look(at, m), children: m.name }, m.id));
201
+ return (_jsx(Row, { icon: _jsx(Mark, { size: 16, "aria-hidden": true }), onPress: () => void look(at, m, ai), children: m.name }, m.id));
192
202
  }), rest > 0 ? (_jsxs(Row, { onPress: () => setAll(true), children: ["Show ", rest, " more"] })) : all && marks.length > FEW ? (_jsx(Row, { onPress: () => setAll(false), children: "Show fewer" })) : null] }));
193
203
  }
194
204
  /**
@@ -200,7 +210,17 @@ function Marks({ at, kind, title, icon, marks, children, }) {
200
210
  * `index.html` something you can actually see rather than something you can
201
211
  * only read the source of.
202
212
  */
203
- function look(at, m) {
213
+ async function look(at, m, api) {
214
+ if (!m.href && m.file) {
215
+ try {
216
+ const f = await fileOf(api, m.file);
217
+ openTab(at, await download(api, f.bucket, f.key), m.name);
218
+ }
219
+ catch {
220
+ // A file forgotten or a store unreachable opens nothing; the row stays.
221
+ }
222
+ return;
223
+ }
204
224
  const href = m.href ?? (m.body === undefined ? null : URL.createObjectURL(new Blob([m.body], { type: m.mime })));
205
225
  if (!href)
206
226
  return;
@@ -244,7 +264,8 @@ const Note = ({ children }) => (_jsx(DropdownMenuLabel, { children: _jsx(Text, {
244
264
  * and they put a skill one press away instead of two.
245
265
  */
246
266
  function Add({ at }) {
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, {})] })] }));
267
+ const ai = useAi();
268
+ 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, { api: ai, org: org() }), children: _jsx(Word, { children: "Attach files" }) }), _jsx(DropdownMenuSeparator, {}), _jsx(Skills, {}), _jsx(DropdownMenuSeparator, {}), _jsx(Services, {})] })] }));
248
269
  }
249
270
  /**
250
271
  * What this org's agents can reach for, as a section of this menu.
@@ -1,43 +1,50 @@
1
1
  /**
2
- * Files that go with a chat message.
2
+ * Files that go with a chat message — by REFERENCE.
3
3
  *
4
- * @hanzo/ai's `send(text, images)` carries a picture as an `image_url` part the
5
- * model reads, and nothing else rides that wire, so a file goes one of two
6
- * ways: an image as a `data:` URI beside the words, a text file as its own
7
- * words after the message, fenced under its name. Any other kind is refused by
8
- * name, before anything is held.
4
+ * A file put into a conversation is uploaded to the org's workspace and indexed
5
+ * there (`lib/files`). The turn carries what NAMES it — id, name, type, size —
6
+ * and the passages read from it for the question, each citing
7
+ * file › section › ¶n. No byte of the file rides the chat request, so there is
8
+ * no size a file must stay under to go with a message.
9
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.
10
+ * The names and the passages travel in one block after the words. The block is
11
+ * what the model reads; `said` takes it back off for drawing, so a person sees
12
+ * their words and the files' chips, never the passages.
12
13
  */
13
14
  import type { MessagePart } from '@hanzo/ui/chat';
14
- /** What a file contributes to a message once admitted. */
15
- export interface Admitted {
16
- kind: 'image' | 'text';
17
- /** A `data:` URI for an image, the words for a text file. */
18
- data: string;
15
+ import type { FileRef, Grounds } from './files.js';
16
+ /** The files a turn reads: those put into it, and those carried from earlier turns. */
17
+ export interface Carried {
18
+ attached: FileRef[];
19
+ reused: FileRef[];
19
20
  }
20
- /** A file as it goes with a message. */
21
- export type Going = Admitted & {
22
- name: string;
23
- };
24
- export declare const IMAGE_MAX: number;
25
- export declare const TEXT_MAX: number;
26
- /** Which way a file goes with a message, or null when it cannot go. */
27
- export declare function kindOf(file: {
28
- name: string;
29
- type: string;
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>;
21
+ /** A size as a person reads it. */
22
+ export declare function weigh(bytes: number): string;
33
23
  /**
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.
24
+ * The message as sent: the words, then one block naming the files and holding
25
+ * what was read from them for this question.
26
+ *
27
+ * `why` says why there are no passages, when there are none — still indexing,
28
+ * stored without indexing, or the read failed — so the model says so rather
29
+ * than answering as though it had read the files.
30
+ */
31
+ export declare function compose(text: string, carried: Carried, grounds: Grounds | null, why?: string): string;
32
+ /** A turn read back: the words a person wrote, and the files it carried. */
33
+ export interface Said {
34
+ text: string;
35
+ carried: Carried | null;
36
+ /** The citations the turn handed the model, numbered as it numbered them. */
37
+ cites: string[];
38
+ }
39
+ /** Takes the files block back off a turn, for drawing. */
40
+ export declare function said(content: string): Said;
41
+ /**
42
+ * Every file the conversation's turns have carried, once each, oldest first —
43
+ * what a later question in the same conversation reads again.
38
44
  */
39
- export declare function compose(text: string, files: readonly Going[]): string;
40
- /** The images of a message, as the URIs @hanzo/ai's `send` takes. */
41
- export declare const images: (files: readonly Going[]) => string[];
45
+ export declare function carriedIn(turns: readonly {
46
+ role: string;
47
+ content?: unknown;
48
+ }[]): FileRef[];
42
49
  /** A wire turn's parts — text and `image_url` — as the parts `@hanzo/ui/chat` draws. */
43
50
  export declare function partsOf(content: readonly unknown[]): MessagePart[];