@browserless/ai 13.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +128 -0
- package/examples/index.js +21 -0
- package/package.json +68 -0
- package/scripts/docker-ci.sh +35 -0
- package/scripts/install-model.js +21 -0
- package/scripts/pack-model.js +172 -0
- package/scripts/probe.js +33 -0
- package/scripts/util.js +274 -0
- package/src/find-dir.js +28 -0
- package/src/index.js +313 -0
- package/src/run-ai.js +151 -0
- package/src/unpack.js +145 -0
package/src/run-ai.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const runAi = async spec => {
|
|
4
|
+
const parseJson = raw => {
|
|
5
|
+
try {
|
|
6
|
+
return JSON.parse(raw)
|
|
7
|
+
} catch (error) {
|
|
8
|
+
const start = String(raw).indexOf('{')
|
|
9
|
+
const end = String(raw).lastIndexOf('}')
|
|
10
|
+
if (start !== -1 && end > start) {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(raw.slice(start, end + 1))
|
|
13
|
+
} catch {}
|
|
14
|
+
}
|
|
15
|
+
const preview = String(raw).replace(/\s+/g, ' ').slice(0, 160)
|
|
16
|
+
throw new Error(`LanguageModel did not return JSON: ${preview}`, { cause: error })
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const names = {
|
|
21
|
+
prompt: 'LanguageModel',
|
|
22
|
+
summarize: 'Summarizer',
|
|
23
|
+
translate: 'Translator',
|
|
24
|
+
detectLanguage: 'LanguageDetector'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (spec.api === 'availability') {
|
|
28
|
+
const probes = {
|
|
29
|
+
languageModel: {
|
|
30
|
+
name: 'LanguageModel',
|
|
31
|
+
opts: {
|
|
32
|
+
expectedInputs: [{ type: 'text', languages: ['en'] }],
|
|
33
|
+
expectedOutputs: [{ type: 'text', languages: ['en'] }]
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
summarizer: {
|
|
37
|
+
name: 'Summarizer',
|
|
38
|
+
opts: { expectedInputLanguages: ['en'], outputLanguage: 'en' }
|
|
39
|
+
},
|
|
40
|
+
translator: {
|
|
41
|
+
name: 'Translator',
|
|
42
|
+
opts: { sourceLanguage: 'en', targetLanguage: 'es' }
|
|
43
|
+
},
|
|
44
|
+
languageDetector: {
|
|
45
|
+
name: 'LanguageDetector',
|
|
46
|
+
opts: { expectedInputLanguages: ['en'] }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const apis = {}
|
|
50
|
+
const ctors = {}
|
|
51
|
+
for (const [api, { name, opts }] of Object.entries(probes)) {
|
|
52
|
+
const Ctor = globalThis[name]
|
|
53
|
+
ctors[api] = typeof Ctor
|
|
54
|
+
if (typeof Ctor === 'undefined') {
|
|
55
|
+
apis[api] = 'unavailable'
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
apis[api] = await Ctor.availability(opts)
|
|
60
|
+
} catch {
|
|
61
|
+
apis[api] = 'unavailable'
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
apis,
|
|
66
|
+
env: {
|
|
67
|
+
hardwareConcurrency: globalThis.navigator && globalThis.navigator.hardwareConcurrency,
|
|
68
|
+
deviceMemory: globalThis.navigator && globalThis.navigator.deviceMemory,
|
|
69
|
+
...Object.fromEntries(Object.entries(ctors).map(([key, value]) => [`ctor_${key}`, value]))
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const schema = spec.schema || spec.responseConstraint
|
|
75
|
+
const name = schema && spec.api === 'summarize' ? 'LanguageModel' : names[spec.api]
|
|
76
|
+
const Ctor = globalThis[name]
|
|
77
|
+
if (typeof Ctor === 'undefined') throw new Error(`${name} is not available`)
|
|
78
|
+
|
|
79
|
+
const createKeys = {
|
|
80
|
+
prompt: ['initialPrompts', 'expectedInputs', 'expectedOutputs', 'temperature', 'topK'],
|
|
81
|
+
summarize: [
|
|
82
|
+
'type',
|
|
83
|
+
'format',
|
|
84
|
+
'length',
|
|
85
|
+
'sharedContext',
|
|
86
|
+
'expectedInputLanguages',
|
|
87
|
+
'outputLanguage',
|
|
88
|
+
'expectedContextLanguages'
|
|
89
|
+
],
|
|
90
|
+
translate: ['sourceLanguage', 'targetLanguage'],
|
|
91
|
+
detectLanguage: ['expectedInputLanguages']
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const keys =
|
|
95
|
+
name === 'LanguageModel' ? createKeys.prompt : createKeys[spec.api] || createKeys.prompt
|
|
96
|
+
const createOpts = {}
|
|
97
|
+
for (const key of keys) {
|
|
98
|
+
if (spec[key] !== undefined) createOpts[key] = spec[key]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (spec.api === 'translate' && (!createOpts.sourceLanguage || !createOpts.targetLanguage)) {
|
|
102
|
+
throw new Error('Translator requires sourceLanguage and targetLanguage')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let availability = await Ctor.availability(createOpts)
|
|
106
|
+
if (availability === 'unavailable') throw new Error(`${name} is unavailable`)
|
|
107
|
+
|
|
108
|
+
if (availability === 'downloading') {
|
|
109
|
+
const started = Date.now()
|
|
110
|
+
while (availability === 'downloading' && Date.now() - started < 240000) {
|
|
111
|
+
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
112
|
+
availability = await Ctor.availability(createOpts)
|
|
113
|
+
}
|
|
114
|
+
if (availability === 'unavailable' || availability === 'downloading') {
|
|
115
|
+
throw new Error(`${name} is ${availability}`)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const instance = await Ctor.create({
|
|
120
|
+
...createOpts,
|
|
121
|
+
monitor (m) {
|
|
122
|
+
m.addEventListener('downloadprogress', e => {
|
|
123
|
+
console.log(`download ${name} ${Math.round(e.loaded * 100)}%`)
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
try {
|
|
128
|
+
const pageText =
|
|
129
|
+
(globalThis.document && globalThis.document.body && globalThis.document.body.innerText) || ''
|
|
130
|
+
const input = spec.text !== undefined ? spec.text : pageText
|
|
131
|
+
if (spec.api === 'prompt' || (schema && name === 'LanguageModel')) {
|
|
132
|
+
let prompt = input
|
|
133
|
+
if (spec.prompt) prompt = input ? `${spec.prompt}\n\n${input}` : spec.prompt
|
|
134
|
+
else if (schema) prompt = `Extract metadata from this page.\n\n${input}`
|
|
135
|
+
const result = await instance.prompt(
|
|
136
|
+
prompt,
|
|
137
|
+
schema ? { responseConstraint: schema } : undefined
|
|
138
|
+
)
|
|
139
|
+
return schema ? parseJson(result) : result
|
|
140
|
+
}
|
|
141
|
+
if (spec.api === 'summarize') {
|
|
142
|
+
return await instance.summarize(input, spec.context ? { context: spec.context } : undefined)
|
|
143
|
+
}
|
|
144
|
+
if (spec.api === 'translate') return await instance.translate(input)
|
|
145
|
+
return await instance.detect(input)
|
|
146
|
+
} finally {
|
|
147
|
+
if (typeof instance.destroy === 'function') instance.destroy()
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = runAi
|
package/src/unpack.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { pipeline } = require('node:stream/promises')
|
|
4
|
+
const { createInflateRaw } = require('node:zlib')
|
|
5
|
+
const { open } = require('node:fs/promises')
|
|
6
|
+
const { Readable } = require('node:stream')
|
|
7
|
+
const path = require('node:path')
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
createReadStream,
|
|
11
|
+
createWriteStream,
|
|
12
|
+
existsSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
renameSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
unlinkSync,
|
|
17
|
+
writeFileSync
|
|
18
|
+
} = require('node:fs')
|
|
19
|
+
|
|
20
|
+
const debug = require('debug-logfmt')('browserless:ai')
|
|
21
|
+
const { hasFile, cacheRoot } = require('./find-dir')
|
|
22
|
+
|
|
23
|
+
const installed = dir => {
|
|
24
|
+
if (!hasFile(dir, 'weights.bin')) return
|
|
25
|
+
const hasAdaptation =
|
|
26
|
+
['prompt', 'summarize', 'detect'].some(name =>
|
|
27
|
+
hasFile(path.join(dir, name), 'model-info.pb')
|
|
28
|
+
) || hasFile(dir, 'model-info.pb')
|
|
29
|
+
if (!hasAdaptation) return
|
|
30
|
+
return { dir }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const writeZip = async (input, dest) => {
|
|
34
|
+
if (input == null) {
|
|
35
|
+
if (!existsSync(dest)) throw new Error('download did not write a zip')
|
|
36
|
+
return dest
|
|
37
|
+
}
|
|
38
|
+
if (typeof input === 'string') return path.resolve(input)
|
|
39
|
+
mkdirSync(path.dirname(dest), { recursive: true })
|
|
40
|
+
if (Buffer.isBuffer(input) || input instanceof Uint8Array) {
|
|
41
|
+
writeFileSync(dest, input)
|
|
42
|
+
return dest
|
|
43
|
+
}
|
|
44
|
+
const body = input.body || input.Body || input
|
|
45
|
+
const stream = typeof body.getReader === 'function' ? Readable.fromWeb(body) : body
|
|
46
|
+
await pipeline(stream, createWriteStream(dest))
|
|
47
|
+
return dest
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const safeDest = (dir, name) => {
|
|
51
|
+
if (name.endsWith('/')) return
|
|
52
|
+
const dest = path.join(dir, name)
|
|
53
|
+
const rel = path.relative(dir, dest)
|
|
54
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
55
|
+
throw new Error(`refusing to extract ${name}`)
|
|
56
|
+
}
|
|
57
|
+
return dest
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const unzipJs = async (zipPath, dir) => {
|
|
61
|
+
const fd = await open(zipPath, 'r')
|
|
62
|
+
try {
|
|
63
|
+
let pos = 0
|
|
64
|
+
const read = async (length, at = pos) => {
|
|
65
|
+
const buf = Buffer.alloc(length)
|
|
66
|
+
const { bytesRead } = await fd.read(buf, 0, length, at)
|
|
67
|
+
return buf.subarray(0, bytesRead)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
while (true) {
|
|
71
|
+
const sig = await read(4, pos)
|
|
72
|
+
if (sig.length < 4 || sig.readUInt32LE(0) !== 0x04034b50) break
|
|
73
|
+
const rest = await read(26, pos + 4)
|
|
74
|
+
const flags = rest.readUInt16LE(2)
|
|
75
|
+
const method = rest.readUInt16LE(4)
|
|
76
|
+
const compressed = rest.readUInt32LE(14)
|
|
77
|
+
const nameLen = rest.readUInt16LE(22)
|
|
78
|
+
const extraLen = rest.readUInt16LE(24)
|
|
79
|
+
const name = (await read(nameLen, pos + 30)).toString()
|
|
80
|
+
if (flags & 0x08) {
|
|
81
|
+
throw new Error(`unsupported zip entry with data descriptor (${name})`)
|
|
82
|
+
}
|
|
83
|
+
const dataStart = pos + 30 + nameLen + extraLen
|
|
84
|
+
const dest = safeDest(dir, name)
|
|
85
|
+
pos = dataStart + compressed
|
|
86
|
+
if (!dest) continue
|
|
87
|
+
if (method !== 0 && method !== 8) {
|
|
88
|
+
throw new Error(`unsupported zip method ${method} (${name})`)
|
|
89
|
+
}
|
|
90
|
+
mkdirSync(path.dirname(dest), { recursive: true })
|
|
91
|
+
process.stderr.write(`extracting ${name}\n`)
|
|
92
|
+
if (compressed === 0) {
|
|
93
|
+
writeFileSync(dest, '')
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
const source = createReadStream(zipPath, {
|
|
97
|
+
start: dataStart,
|
|
98
|
+
end: dataStart + compressed - 1
|
|
99
|
+
})
|
|
100
|
+
const destStream = createWriteStream(dest)
|
|
101
|
+
if (method === 8) await pipeline(source, createInflateRaw(), destStream)
|
|
102
|
+
else await pipeline(source, destStream)
|
|
103
|
+
}
|
|
104
|
+
} finally {
|
|
105
|
+
await fd.close()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const unzip = async (zipPath, dir) => {
|
|
110
|
+
mkdirSync(dir, { recursive: true })
|
|
111
|
+
await unzipJs(zipPath, dir)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const unpack = async (get, { dir, force = false } = {}) => {
|
|
115
|
+
if (get == null) throw new Error('unpack requires a zip path or download function')
|
|
116
|
+
dir = path.resolve(dir || process.env.BROWSERLESS_AI_DIR || cacheRoot())
|
|
117
|
+
mkdirSync(dir, { recursive: true })
|
|
118
|
+
|
|
119
|
+
const already = installed(dir)
|
|
120
|
+
debug('unpack', { dir, force, installed: Boolean(already) })
|
|
121
|
+
if (already && !force) return already
|
|
122
|
+
|
|
123
|
+
const staging = `${dir}.partial`
|
|
124
|
+
rmSync(staging, { recursive: true, force: true })
|
|
125
|
+
mkdirSync(staging, { recursive: true })
|
|
126
|
+
try {
|
|
127
|
+
const dest = path.join(staging, 'bundle.zip')
|
|
128
|
+
const zipPath =
|
|
129
|
+
typeof get === 'function' ? await writeZip(await get(dest), dest) : path.resolve(get)
|
|
130
|
+
if (!existsSync(zipPath)) throw new Error(`missing zip: ${zipPath}`)
|
|
131
|
+
|
|
132
|
+
await unzip(zipPath, staging)
|
|
133
|
+
if (zipPath === dest) unlinkSync(dest)
|
|
134
|
+
const paths = installed(staging)
|
|
135
|
+
if (!paths) throw new Error('bundle did not contain nano weights and an adaptation')
|
|
136
|
+
rmSync(dir, { recursive: true, force: true })
|
|
137
|
+
renameSync(staging, dir)
|
|
138
|
+
return { dir }
|
|
139
|
+
} catch (error) {
|
|
140
|
+
rmSync(staging, { recursive: true, force: true })
|
|
141
|
+
throw error
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = unpack
|