@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/Chat.js +145 -17
- package/dist/Contents.d.ts +23 -0
- package/dist/Contents.js +84 -0
- package/dist/Drive.js +131 -39
- 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 +1 -1
package/dist/lib/attach.js
CHANGED
|
@@ -1,76 +1,111 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Files that go with a chat message.
|
|
2
|
+
* Files that go with a chat message — by REFERENCE.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (
|
|
20
|
-
return
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
14
|
+
const OPEN = '<workspace-files>';
|
|
15
|
+
const CLOSE = '</workspace-files>';
|
|
16
|
+
/** A size as a person reads it. */
|
|
17
|
+
export function weigh(bytes) {
|
|
18
|
+
if (!Number.isFinite(bytes) || bytes < 0)
|
|
19
|
+
return '';
|
|
20
|
+
if (bytes < 1024)
|
|
21
|
+
return `${bytes} B`;
|
|
22
|
+
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
23
|
+
let n = bytes / 1024;
|
|
24
|
+
let u = 0;
|
|
25
|
+
while (n >= 1024 && u < units.length - 1) {
|
|
26
|
+
n /= 1024;
|
|
27
|
+
u++;
|
|
28
|
+
}
|
|
29
|
+
return `${n >= 10 ? Math.round(n) : n.toFixed(1)} ${units[u]}`;
|
|
24
30
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
/** Text that cannot close the block it sits in. */
|
|
32
|
+
const inert = (s) => s.split(CLOSE).join('</workspace files>');
|
|
33
|
+
/**
|
|
34
|
+
* The message as sent: the words, then one block naming the files and holding
|
|
35
|
+
* what was read from them for this question.
|
|
36
|
+
*
|
|
37
|
+
* `why` says why there are no passages, when there are none — still indexing,
|
|
38
|
+
* stored without indexing, or the read failed — so the model says so rather
|
|
39
|
+
* than answering as though it had read the files.
|
|
40
|
+
*/
|
|
41
|
+
export function compose(text, carried, grounds, why) {
|
|
42
|
+
const words = text.trim();
|
|
43
|
+
const all = [...carried.attached, ...carried.reused];
|
|
44
|
+
if (!all.length)
|
|
45
|
+
return words;
|
|
46
|
+
const passages = grounds?.passages ?? [];
|
|
47
|
+
const lines = [
|
|
48
|
+
OPEN,
|
|
49
|
+
JSON.stringify({ attached: carried.attached, reused: carried.reused }),
|
|
50
|
+
`The person's workspace holds these files: ${all.map((f) => `${f.name} (${f.type || 'file'}, ${weigh(f.size)})`).join('; ')}.`,
|
|
51
|
+
];
|
|
52
|
+
if (passages.length) {
|
|
53
|
+
lines.push(words
|
|
54
|
+
? 'Answer from the passages below, read from those files for this question. Cite each claim with its number, like [1], and end with a Sources list giving each passage you cited as "[n] file › section › ¶n". If the passages do not answer the question, say what is missing.'
|
|
55
|
+
: 'The person sent these files without a question: say what each one is and what it covers, from the passages below, citing them like [1].');
|
|
56
|
+
passages.forEach((p, i) => {
|
|
57
|
+
lines.push('', `[${i + 1}] ${inert(p.cite)}`, inert(p.text.trim()));
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
lines.push(`No passages could be read from them${why ? ` (${why})` : ''}: say so, and answer only what you can without them.`);
|
|
62
|
+
}
|
|
63
|
+
lines.push(CLOSE);
|
|
64
|
+
const block = lines.join('\n');
|
|
65
|
+
return words ? `${words}\n\n${block}` : block;
|
|
35
66
|
}
|
|
36
|
-
/**
|
|
37
|
-
export
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
40
|
-
return
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
67
|
+
/** Takes the files block back off a turn, for drawing. */
|
|
68
|
+
export function said(content) {
|
|
69
|
+
const at = content.startsWith(`${OPEN}\n`) ? 0 : content.lastIndexOf(`\n${OPEN}\n`);
|
|
70
|
+
if (at < 0)
|
|
71
|
+
return { text: content, carried: null, cites: [] };
|
|
72
|
+
const start = content.indexOf(OPEN, at) + OPEN.length + 1;
|
|
73
|
+
const end = content.indexOf(CLOSE, start);
|
|
74
|
+
const body = content.slice(start, end < 0 ? undefined : end);
|
|
75
|
+
const nl = body.indexOf('\n');
|
|
44
76
|
try {
|
|
45
|
-
|
|
77
|
+
const head = JSON.parse(nl < 0 ? body : body.slice(0, nl));
|
|
78
|
+
const carried = {
|
|
79
|
+
attached: Array.isArray(head.attached) ? head.attached.filter(isRef) : [],
|
|
80
|
+
reused: Array.isArray(head.reused) ? head.reused.filter(isRef) : [],
|
|
81
|
+
};
|
|
82
|
+
const cites = [...body.matchAll(/^\[(\d+)\] (.+)$/gm)].map((m) => m[2]);
|
|
83
|
+
return { text: content.slice(0, at).trimEnd(), carried, cites };
|
|
46
84
|
}
|
|
47
85
|
catch {
|
|
48
|
-
return
|
|
86
|
+
return { text: content, carried: null, cites: [] };
|
|
49
87
|
}
|
|
50
88
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return '`'.repeat(Math.max(3, longest + 1));
|
|
89
|
+
function isRef(v) {
|
|
90
|
+
const r = v;
|
|
91
|
+
return !!r && typeof r.id === 'string' && typeof r.name === 'string';
|
|
55
92
|
}
|
|
56
93
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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.
|
|
94
|
+
* Every file the conversation's turns have carried, once each, oldest first —
|
|
95
|
+
* what a later question in the same conversation reads again.
|
|
61
96
|
*/
|
|
62
|
-
export function
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
97
|
+
export function carriedIn(turns) {
|
|
98
|
+
const out = new Map();
|
|
99
|
+
for (const t of turns) {
|
|
100
|
+
if (t.role !== 'user' || typeof t.content !== 'string')
|
|
101
|
+
continue;
|
|
102
|
+
const c = said(t.content).carried;
|
|
103
|
+
for (const f of [...(c?.attached ?? []), ...(c?.reused ?? [])])
|
|
104
|
+
if (!out.has(f.id))
|
|
105
|
+
out.set(f.id, f);
|
|
106
|
+
}
|
|
107
|
+
return [...out.values()];
|
|
71
108
|
}
|
|
72
|
-
/** The images of a message, as the URIs @hanzo/ai's `send` takes. */
|
|
73
|
-
export const images = (files) => files.filter((f) => f.kind === 'image').map((f) => f.data);
|
|
74
109
|
/** A wire turn's parts — text and `image_url` — as the parts `@hanzo/ui/chat` draws. */
|
|
75
110
|
export function partsOf(content) {
|
|
76
111
|
const out = [];
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WORKSPACE FILES — a dropped file goes to the org's own storage, is indexed
|
|
3
|
+
* there, and is asked about by reference.
|
|
4
|
+
*
|
|
5
|
+
* The bytes never ride a chat request and never pass through the API: a small
|
|
6
|
+
* file is one presigned PUT, a large one a multipart upload whose parts are
|
|
7
|
+
* presigned PUTs straight to Hanzo S3 (resumable — the store keeps the parts it
|
|
8
|
+
* has, and a second attempt sends only the rest). The file then lands in the
|
|
9
|
+
* workspace's Drive bucket, is registered with knowledge (`/v1/knowledge/files`)
|
|
10
|
+
* and indexed in three layers on the platform's durable queue: its table of
|
|
11
|
+
* contents, passages cut inside its sections, and a graph to the other files.
|
|
12
|
+
*
|
|
13
|
+
* A chat turn carries the file's REFERENCE — id, name, type, size — and the
|
|
14
|
+
* passages `retrieve` reads for the question, each citing file › section › ¶n.
|
|
15
|
+
*/
|
|
16
|
+
import type { HttpClient } from '@hanzo/ai';
|
|
17
|
+
/** The HTTP half of an `@hanzo/ai` client: all this module needs. */
|
|
18
|
+
export interface Api {
|
|
19
|
+
http: Pick<HttpClient, 'json'>;
|
|
20
|
+
}
|
|
21
|
+
/** A workspace file as knowledge records it. */
|
|
22
|
+
export interface WorkFile {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
type: string;
|
|
26
|
+
size: number;
|
|
27
|
+
bucket: string;
|
|
28
|
+
key: string;
|
|
29
|
+
status: 'queued' | 'indexing' | 'ready' | 'stored' | 'failed';
|
|
30
|
+
stage?: 'extract' | 'toc' | 'passages' | 'embed' | 'graph';
|
|
31
|
+
error?: string;
|
|
32
|
+
chars?: number;
|
|
33
|
+
clipped?: boolean;
|
|
34
|
+
sections?: number;
|
|
35
|
+
passages?: number;
|
|
36
|
+
embedded?: number;
|
|
37
|
+
parent?: string;
|
|
38
|
+
}
|
|
39
|
+
/** What a chat message carries of a file: enough to name it and ask about it. */
|
|
40
|
+
export interface FileRef {
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
type: string;
|
|
44
|
+
size: number;
|
|
45
|
+
}
|
|
46
|
+
export declare const refOf: (f: Pick<WorkFile, 'id' | 'name' | 'type' | 'size'>) => FileRef;
|
|
47
|
+
/** A file's ingest has ended, one way or the other. */
|
|
48
|
+
export declare const settled: (f: WorkFile) => boolean;
|
|
49
|
+
/** A file can be asked about: its passages are written, even while it embeds. */
|
|
50
|
+
export declare const askable: (f: WorkFile) => boolean;
|
|
51
|
+
/** The name a bucket takes from its workspace: a DNS-safe slug, at most 40. */
|
|
52
|
+
export declare const slug: (name: string) => string;
|
|
53
|
+
export declare function workspace(api: Api, org: string): Promise<string>;
|
|
54
|
+
/** Runs `work` over `items`, at most `n` at a time, and settles when all have. */
|
|
55
|
+
export declare function pool<T>(items: readonly T[], n: number, work: (item: T) => Promise<void>): Promise<void>;
|
|
56
|
+
/** Files over this go up in parts. */
|
|
57
|
+
export declare const MULTIPART_AT: number;
|
|
58
|
+
/**
|
|
59
|
+
* Uploads a file to bucket/key, reporting the fraction sent. A large file goes
|
|
60
|
+
* in parts; one that was interrupted — a dropped connection, a reload — resumes
|
|
61
|
+
* from the parts the store already holds.
|
|
62
|
+
*/
|
|
63
|
+
export declare function upload(api: Api, bucket: string, key: string, file: File, progress: (fraction: number) => void, signal?: AbortSignal): Promise<void>;
|
|
64
|
+
/** Registers an uploaded object as a workspace file and queues its ingest. */
|
|
65
|
+
export declare const register: (api: Api, bucket: string, key: string) => Promise<WorkFile>;
|
|
66
|
+
/** One file's record, with how far its ingest has got. */
|
|
67
|
+
export declare const fileOf: (api: Api, id: string) => Promise<WorkFile>;
|
|
68
|
+
/** The files of one bucket, for Drive's index state. */
|
|
69
|
+
export declare const filesIn: (api: Api, bucket: string) => Promise<WorkFile[]>;
|
|
70
|
+
/** Removes a file from the index; the object stays where it is. */
|
|
71
|
+
export declare const forget: (api: Api, id: string) => Promise<unknown>;
|
|
72
|
+
/**
|
|
73
|
+
* Polls a file until its ingest settles, calling `seen` with each new record.
|
|
74
|
+
* Answers the last record; stops early when `signal` aborts.
|
|
75
|
+
*/
|
|
76
|
+
export declare function follow(api: Api, id: string, seen: (f: WorkFile) => void, signal?: AbortSignal): Promise<WorkFile>;
|
|
77
|
+
/**
|
|
78
|
+
* Waits, at most `ms`, for each file to have passages to read or to settle,
|
|
79
|
+
* and answers what the index last said of each.
|
|
80
|
+
*/
|
|
81
|
+
export declare function readable(api: Api, list: readonly WorkFile[], ms: number): Promise<WorkFile[]>;
|
|
82
|
+
/** Where a file is under a key: the chat's folder, with a dropped folder's own path kept. */
|
|
83
|
+
export declare const chatKey: (file: File) => string;
|
|
84
|
+
/**
|
|
85
|
+
* Every file in a drop, folders walked: a dropped folder arrives as entries, not
|
|
86
|
+
* files, and each file found under it keeps its path (`relative`).
|
|
87
|
+
*/
|
|
88
|
+
export declare function dropped(dt: DataTransfer): Promise<File[]>;
|
|
89
|
+
/** One passage, with where it is. */
|
|
90
|
+
export interface Passage {
|
|
91
|
+
file: {
|
|
92
|
+
id: string;
|
|
93
|
+
name: string;
|
|
94
|
+
type: string;
|
|
95
|
+
bucket: string;
|
|
96
|
+
key: string;
|
|
97
|
+
};
|
|
98
|
+
section: {
|
|
99
|
+
id: number;
|
|
100
|
+
title: string;
|
|
101
|
+
path: string;
|
|
102
|
+
};
|
|
103
|
+
part: number;
|
|
104
|
+
text: string;
|
|
105
|
+
cite: string;
|
|
106
|
+
score: number;
|
|
107
|
+
via: 'search' | 'toc' | 'graph';
|
|
108
|
+
why?: string;
|
|
109
|
+
}
|
|
110
|
+
/** What `retrieve` grounds an answer in. */
|
|
111
|
+
export interface Grounds {
|
|
112
|
+
sections: {
|
|
113
|
+
file: Passage['file'];
|
|
114
|
+
section: Passage['section'];
|
|
115
|
+
summary?: string;
|
|
116
|
+
}[];
|
|
117
|
+
passages: Passage[];
|
|
118
|
+
picked: 'model' | 'search';
|
|
119
|
+
degraded?: boolean;
|
|
120
|
+
}
|
|
121
|
+
/** ToC-first retrieval: the sections a model picks from the files' tables of contents, their passages, and the graph's. */
|
|
122
|
+
export declare const retrieve: (api: Api, query: string, files: string[], limit?: number) => Promise<Grounds>;
|
|
123
|
+
/** Passages of the org's files that match, for Drive's search. */
|
|
124
|
+
export declare const search: (api: Api, query: string, limit?: number) => Promise<{
|
|
125
|
+
passages: Passage[];
|
|
126
|
+
}>;
|
|
127
|
+
/** One node of a file's table of contents. */
|
|
128
|
+
export interface TocEntry {
|
|
129
|
+
id: number;
|
|
130
|
+
parent: number;
|
|
131
|
+
level: number;
|
|
132
|
+
title: string;
|
|
133
|
+
summary?: string;
|
|
134
|
+
size: number;
|
|
135
|
+
synthetic?: boolean;
|
|
136
|
+
}
|
|
137
|
+
export declare const toc: (api: Api, id: string) => Promise<{
|
|
138
|
+
file: WorkFile;
|
|
139
|
+
sections: TocEntry[];
|
|
140
|
+
}>;
|
|
141
|
+
/** One section, opened. */
|
|
142
|
+
export interface Opened {
|
|
143
|
+
file: Passage['file'];
|
|
144
|
+
section: Passage['section'];
|
|
145
|
+
summary?: string;
|
|
146
|
+
text: string;
|
|
147
|
+
children: TocEntry[];
|
|
148
|
+
links: {
|
|
149
|
+
file: Passage['file'];
|
|
150
|
+
section: Passage['section'];
|
|
151
|
+
kind: string;
|
|
152
|
+
label: string;
|
|
153
|
+
}[];
|
|
154
|
+
entities: {
|
|
155
|
+
name: string;
|
|
156
|
+
kind: string;
|
|
157
|
+
files: number;
|
|
158
|
+
}[];
|
|
159
|
+
}
|
|
160
|
+
export declare const section: (api: Api, id: string, n: number) => Promise<Opened>;
|
|
161
|
+
/** A signed download address for a file's bytes. */
|
|
162
|
+
export declare const download: (api: Api, bucket: string, key: string) => Promise<string>;
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WORKSPACE FILES — a dropped file goes to the org's own storage, is indexed
|
|
3
|
+
* there, and is asked about by reference.
|
|
4
|
+
*
|
|
5
|
+
* The bytes never ride a chat request and never pass through the API: a small
|
|
6
|
+
* file is one presigned PUT, a large one a multipart upload whose parts are
|
|
7
|
+
* presigned PUTs straight to Hanzo S3 (resumable — the store keeps the parts it
|
|
8
|
+
* has, and a second attempt sends only the rest). The file then lands in the
|
|
9
|
+
* workspace's Drive bucket, is registered with knowledge (`/v1/knowledge/files`)
|
|
10
|
+
* and indexed in three layers on the platform's durable queue: its table of
|
|
11
|
+
* contents, passages cut inside its sections, and a graph to the other files.
|
|
12
|
+
*
|
|
13
|
+
* A chat turn carries the file's REFERENCE — id, name, type, size — and the
|
|
14
|
+
* passages `retrieve` reads for the question, each citing file › section › ¶n.
|
|
15
|
+
*/
|
|
16
|
+
export const refOf = (f) => ({ id: f.id, name: f.name, type: f.type, size: f.size });
|
|
17
|
+
/** A file's ingest has ended, one way or the other. */
|
|
18
|
+
export const settled = (f) => f.status === 'ready' || f.status === 'stored' || f.status === 'failed';
|
|
19
|
+
/** A file can be asked about: its passages are written, even while it embeds. */
|
|
20
|
+
export const askable = (f) => f.status === 'ready' || (f.status === 'indexing' && (f.stage === 'embed' || f.stage === 'graph') && (f.passages ?? 0) > 0);
|
|
21
|
+
/** The name a bucket takes from its workspace: a DNS-safe slug, at most 40. */
|
|
22
|
+
export const slug = (name) => name
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
25
|
+
.replace(/^-+|-+$/g, '')
|
|
26
|
+
.slice(0, 40)
|
|
27
|
+
.replace(/-+$/, '') || 'workspace';
|
|
28
|
+
const objects = (bucket) => `/v1/s3/buckets/${encodeURIComponent(bucket)}`;
|
|
29
|
+
/**
|
|
30
|
+
* THE WORKSPACE'S BUCKET: the one named for the org, made on first use. Drive
|
|
31
|
+
* opens it by default, so what a chat stores is where Drive looks.
|
|
32
|
+
*/
|
|
33
|
+
const buckets = new Map();
|
|
34
|
+
export function workspace(api, org) {
|
|
35
|
+
const name = slug(org);
|
|
36
|
+
let found = buckets.get(name);
|
|
37
|
+
if (!found) {
|
|
38
|
+
found = (async () => {
|
|
39
|
+
const out = await api.http.json({ path: '/v1/s3/buckets' });
|
|
40
|
+
if (!(out?.buckets ?? []).some((b) => b.name === name)) {
|
|
41
|
+
await api.http.json({ method: 'POST', path: '/v1/s3/buckets', body: { name } }).catch((e) => {
|
|
42
|
+
// Two tabs racing the first upload both try to make it; the loser's 409 is the bucket existing.
|
|
43
|
+
if (!/409|exist/i.test(String(e?.message ?? e)))
|
|
44
|
+
throw e;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return name;
|
|
48
|
+
})();
|
|
49
|
+
buckets.set(name, found);
|
|
50
|
+
found.catch(() => buckets.delete(name));
|
|
51
|
+
}
|
|
52
|
+
return found;
|
|
53
|
+
}
|
|
54
|
+
/** One PUT of a body to a presigned URL, reporting bytes sent. */
|
|
55
|
+
function put(url, body, type, sent, signal) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const xhr = new XMLHttpRequest();
|
|
58
|
+
xhr.open('PUT', url);
|
|
59
|
+
if (type)
|
|
60
|
+
xhr.setRequestHeader('Content-Type', type);
|
|
61
|
+
xhr.upload.onprogress = (e) => sent(e.loaded);
|
|
62
|
+
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? (sent(body.size), resolve()) : reject(new Error(`the store answered ${xhr.status}`)));
|
|
63
|
+
xhr.onerror = () => reject(new Error('the upload was interrupted'));
|
|
64
|
+
xhr.onabort = () => reject(new DOMException('aborted', 'AbortError'));
|
|
65
|
+
signal?.addEventListener('abort', () => xhr.abort(), { once: true });
|
|
66
|
+
xhr.send(body);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** Runs `work` over `items`, at most `n` at a time, and settles when all have. */
|
|
70
|
+
export async function pool(items, n, work) {
|
|
71
|
+
let next = 0;
|
|
72
|
+
const lane = async () => {
|
|
73
|
+
while (next < items.length)
|
|
74
|
+
await work(items[next++]);
|
|
75
|
+
};
|
|
76
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(n, items.length)) }, lane));
|
|
77
|
+
}
|
|
78
|
+
/** Files over this go up in parts. */
|
|
79
|
+
export const MULTIPART_AT = 8 * 1024 * 1024;
|
|
80
|
+
/** How many parts are in flight at once. */
|
|
81
|
+
const LANES = 4;
|
|
82
|
+
const resumeKey = (bucket, key, file) => `hanzo.upload.${bucket}/${key}/${file.size}/${file.lastModified}`;
|
|
83
|
+
function remembered(k) {
|
|
84
|
+
try {
|
|
85
|
+
return window.localStorage.getItem(k);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function remember(k, id) {
|
|
92
|
+
try {
|
|
93
|
+
if (id)
|
|
94
|
+
window.localStorage.setItem(k, id);
|
|
95
|
+
else
|
|
96
|
+
window.localStorage.removeItem(k);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// A private window keeps no resume point; the upload still goes.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Uploads a file to bucket/key, reporting the fraction sent. A large file goes
|
|
104
|
+
* in parts; one that was interrupted — a dropped connection, a reload — resumes
|
|
105
|
+
* from the parts the store already holds.
|
|
106
|
+
*/
|
|
107
|
+
export async function upload(api, bucket, key, file, progress, signal) {
|
|
108
|
+
const type = file.type || undefined;
|
|
109
|
+
if (file.size < MULTIPART_AT) {
|
|
110
|
+
const grant = await api.http.json({ method: 'POST', path: `${objects(bucket)}/objects`, body: { bucket, key } });
|
|
111
|
+
if (!grant?.url)
|
|
112
|
+
throw new Error('The store issued no upload address.');
|
|
113
|
+
await put(grant.url, file, type, (n) => progress(file.size ? n / file.size : 1), signal);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const rk = resumeKey(bucket, key, file);
|
|
117
|
+
// The resume point is the upload id and the part size it was cut at: a part
|
|
118
|
+
// the store holds is only the same part when it is cut at the same size.
|
|
119
|
+
const [had, cut] = (remembered(rk) ?? '').split(' ');
|
|
120
|
+
let upload = had || null;
|
|
121
|
+
let partSize = Number(cut) || 16 * 1024 * 1024;
|
|
122
|
+
const done = new Map();
|
|
123
|
+
if (upload) {
|
|
124
|
+
try {
|
|
125
|
+
const had = await api.http.json({
|
|
126
|
+
path: `${objects(bucket)}/uploads/${encodeURIComponent(upload)}`,
|
|
127
|
+
query: { key },
|
|
128
|
+
});
|
|
129
|
+
for (const p of had?.parts ?? [])
|
|
130
|
+
done.set(p.part, p.size);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
upload = null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!upload) {
|
|
137
|
+
const started = await api.http.json({
|
|
138
|
+
method: 'POST',
|
|
139
|
+
path: `${objects(bucket)}/uploads`,
|
|
140
|
+
body: { bucket, key, type },
|
|
141
|
+
});
|
|
142
|
+
upload = started.upload;
|
|
143
|
+
partSize = started.partSize || partSize;
|
|
144
|
+
remember(rk, `${upload} ${partSize}`);
|
|
145
|
+
}
|
|
146
|
+
const id = upload;
|
|
147
|
+
const count = Math.max(1, Math.ceil(file.size / partSize));
|
|
148
|
+
const sent = new Map();
|
|
149
|
+
for (const [n, size] of done)
|
|
150
|
+
sent.set(n, size);
|
|
151
|
+
const report = () => progress([...sent.values()].reduce((a, b) => a + b, 0) / file.size);
|
|
152
|
+
report();
|
|
153
|
+
const todo = [];
|
|
154
|
+
for (let n = 1; n <= count; n++) {
|
|
155
|
+
const want = Math.min(partSize, file.size - (n - 1) * partSize);
|
|
156
|
+
if (done.get(n) !== want)
|
|
157
|
+
todo.push(n);
|
|
158
|
+
}
|
|
159
|
+
const urls = new Map();
|
|
160
|
+
const mint = async (from) => {
|
|
161
|
+
const batch = todo.slice(from, from + 32).filter((n) => !urls.has(n));
|
|
162
|
+
if (!batch.length)
|
|
163
|
+
return;
|
|
164
|
+
const out = await api.http.json({
|
|
165
|
+
method: 'POST',
|
|
166
|
+
path: `${objects(bucket)}/uploads/${encodeURIComponent(id)}/parts`,
|
|
167
|
+
body: { bucket, upload: id, key, parts: batch },
|
|
168
|
+
});
|
|
169
|
+
for (const u of out?.urls ?? [])
|
|
170
|
+
urls.set(u.part, u.url);
|
|
171
|
+
};
|
|
172
|
+
let next = 0;
|
|
173
|
+
const lane = async () => {
|
|
174
|
+
while (next < todo.length) {
|
|
175
|
+
if (signal?.aborted)
|
|
176
|
+
throw new DOMException('aborted', 'AbortError');
|
|
177
|
+
const i = next++;
|
|
178
|
+
const n = todo[i];
|
|
179
|
+
if (!urls.has(n))
|
|
180
|
+
await mint(i);
|
|
181
|
+
const body = file.slice((n - 1) * partSize, Math.min(file.size, n * partSize));
|
|
182
|
+
for (let attempt = 0;; attempt++) {
|
|
183
|
+
try {
|
|
184
|
+
await put(urls.get(n), body, undefined, (b) => (sent.set(n, b), report()), signal);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
if (e?.name === 'AbortError' || attempt === 3)
|
|
189
|
+
throw e;
|
|
190
|
+
// A URL minted minutes ago may have expired: mint the part again.
|
|
191
|
+
urls.delete(n);
|
|
192
|
+
await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
|
|
193
|
+
await mint(i);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
await Promise.all(Array.from({ length: Math.min(LANES, todo.length || 1) }, lane));
|
|
199
|
+
await api.http.json({
|
|
200
|
+
method: 'POST',
|
|
201
|
+
path: `${objects(bucket)}/uploads/${encodeURIComponent(id)}/complete`,
|
|
202
|
+
body: { bucket, upload: id, key },
|
|
203
|
+
});
|
|
204
|
+
remember(rk, null);
|
|
205
|
+
}
|
|
206
|
+
/** Registers an uploaded object as a workspace file and queues its ingest. */
|
|
207
|
+
export const register = (api, bucket, key) => api.http.json({ method: 'POST', path: '/v1/knowledge/files', body: { bucket, key } });
|
|
208
|
+
/** One file's record, with how far its ingest has got. */
|
|
209
|
+
export const fileOf = (api, id) => api.http.json({ path: `/v1/knowledge/files/${encodeURIComponent(id)}` });
|
|
210
|
+
/** The files of one bucket, for Drive's index state. */
|
|
211
|
+
export const filesIn = (api, bucket) => api.http
|
|
212
|
+
.json({ path: '/v1/knowledge/files', query: { bucket, limit: 1000 } })
|
|
213
|
+
.then((out) => out?.files ?? []);
|
|
214
|
+
/** Removes a file from the index; the object stays where it is. */
|
|
215
|
+
export const forget = (api, id) => api.http.json({ method: 'DELETE', path: `/v1/knowledge/files/${encodeURIComponent(id)}` });
|
|
216
|
+
/**
|
|
217
|
+
* Polls a file until its ingest settles, calling `seen` with each new record.
|
|
218
|
+
* Answers the last record; stops early when `signal` aborts.
|
|
219
|
+
*/
|
|
220
|
+
export async function follow(api, id, seen, signal) {
|
|
221
|
+
let wait = 800;
|
|
222
|
+
for (;;) {
|
|
223
|
+
const f = await fileOf(api, id);
|
|
224
|
+
seen(f);
|
|
225
|
+
if (settled(f) || signal?.aborted)
|
|
226
|
+
return f;
|
|
227
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
228
|
+
wait = Math.min(wait * 1.4, 5000);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Waits, at most `ms`, for each file to have passages to read or to settle,
|
|
233
|
+
* and answers what the index last said of each.
|
|
234
|
+
*/
|
|
235
|
+
export async function readable(api, list, ms) {
|
|
236
|
+
const until = Date.now() + ms;
|
|
237
|
+
let now = list.slice();
|
|
238
|
+
while (Date.now() < until && now.some((f) => !askable(f) && !settled(f))) {
|
|
239
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
240
|
+
now = await Promise.all(now.map((f) => (askable(f) || settled(f) ? f : fileOf(api, f.id).catch(() => f))));
|
|
241
|
+
}
|
|
242
|
+
return now;
|
|
243
|
+
}
|
|
244
|
+
/** Where a file is under a key: the chat's folder, with a dropped folder's own path kept. */
|
|
245
|
+
export const chatKey = (file) => {
|
|
246
|
+
const rel = file.relative || file.webkitRelativePath || file.name;
|
|
247
|
+
return `chat/${rel.replace(/^\/+/, '').replace(/\.\.+\//g, '')}`;
|
|
248
|
+
};
|
|
249
|
+
/**
|
|
250
|
+
* Every file in a drop, folders walked: a dropped folder arrives as entries, not
|
|
251
|
+
* files, and each file found under it keeps its path (`relative`).
|
|
252
|
+
*/
|
|
253
|
+
export async function dropped(dt) {
|
|
254
|
+
const items = Array.from(dt.items ?? []).filter((i) => i.kind === 'file');
|
|
255
|
+
const entries = items.map((i) => i.webkitGetAsEntry?.() ?? null);
|
|
256
|
+
if (!entries.some((e) => e?.isDirectory))
|
|
257
|
+
return Array.from(dt.files ?? []);
|
|
258
|
+
const out = [];
|
|
259
|
+
const walk = async (entry, prefix) => {
|
|
260
|
+
if (entry.isFile) {
|
|
261
|
+
const file = await new Promise((res, rej) => entry.file(res, rej));
|
|
262
|
+
Object.defineProperty(file, 'relative', { value: prefix + file.name });
|
|
263
|
+
out.push(file);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const reader = entry.createReader();
|
|
267
|
+
for (;;) {
|
|
268
|
+
const batch = await new Promise((res, rej) => reader.readEntries(res, rej));
|
|
269
|
+
if (!batch.length)
|
|
270
|
+
break;
|
|
271
|
+
for (const child of batch)
|
|
272
|
+
await walk(child, `${prefix}${entry.name}/`);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
for (const e of entries)
|
|
276
|
+
if (e)
|
|
277
|
+
await walk(e, '');
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
/** ToC-first retrieval: the sections a model picks from the files' tables of contents, their passages, and the graph's. */
|
|
281
|
+
export const retrieve = (api, query, files, limit = 8) => api.http.json({ method: 'POST', path: '/v1/knowledge/files/retrieve', body: { query, files, limit } });
|
|
282
|
+
/** Passages of the org's files that match, for Drive's search. */
|
|
283
|
+
export const search = (api, query, limit = 12) => api.http.json({ method: 'POST', path: '/v1/knowledge/files/search', body: { query, limit } });
|
|
284
|
+
export const toc = (api, id) => api.http.json({ path: `/v1/knowledge/files/${encodeURIComponent(id)}/toc` });
|
|
285
|
+
export const section = (api, id, n) => api.http.json({ path: `/v1/knowledge/files/${encodeURIComponent(id)}/sections/${n}` });
|
|
286
|
+
/** A signed download address for a file's bytes. */
|
|
287
|
+
export const download = (api, bucket, key) => api.http
|
|
288
|
+
.json({ path: `${objects(bucket)}/objects/${key.split('/').map(encodeURIComponent).join('/')}` })
|
|
289
|
+
.then((g) => g.url);
|