pinokiod 7.5.42 → 7.5.44
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/package.json +1 -1
- package/server/index.js +25 -0
- package/server/lib/privacy_filter_cache.js +286 -0
- package/server/public/logs-top-redaction.js +26 -3
- package/server/public/logs.js +45 -5
- package/server/public/privacy_filter_worker.js +149 -7
- package/test/logs-ask-ai.test.js +60 -1
- package/test/privacy-filter-cache.test.js +242 -0
package/package.json
CHANGED
package/server/index.js
CHANGED
|
@@ -85,6 +85,7 @@ const { createTaskWorkspaceLinkService } = require("./lib/task_workspace_links")
|
|
|
85
85
|
const { createContentValidationService } = require("./lib/content_validation")
|
|
86
86
|
const { buildSecureRouterDebugSnapshot, createSecureRouterDebugStore } = require("./lib/secure_router_debug")
|
|
87
87
|
const logRedaction = require("./lib/log_redaction")
|
|
88
|
+
const privacyFilterCache = require("./lib/privacy_filter_cache")
|
|
88
89
|
const AppRegistryService = require("./lib/app_registry")
|
|
89
90
|
const AppLogService = require("./lib/app_logs")
|
|
90
91
|
const AppLogReportService = require("./lib/app_log_report")
|
|
@@ -6401,6 +6402,12 @@ class Server {
|
|
|
6401
6402
|
}
|
|
6402
6403
|
});
|
|
6403
6404
|
this.app.use('/files', serve2, serveIndex(this.kernel.homedir, {icons: true, hidden: true, theme: this.theme }))
|
|
6405
|
+
this.app.use('/pinokio/privacy-filter/models', express.static(privacyFilterCache.cacheRoot(this.kernel), {
|
|
6406
|
+
fallthrough: true,
|
|
6407
|
+
index: false,
|
|
6408
|
+
immutable: true,
|
|
6409
|
+
maxAge: '1y'
|
|
6410
|
+
}))
|
|
6404
6411
|
} else {
|
|
6405
6412
|
this.app.set("views", [
|
|
6406
6413
|
path.resolve(__dirname, "views")
|
|
@@ -8415,6 +8422,24 @@ class Server {
|
|
|
8415
8422
|
entries
|
|
8416
8423
|
})
|
|
8417
8424
|
}))
|
|
8425
|
+
this.app.get("/pinokio/privacy-filter/status", ex(async (req, res) => {
|
|
8426
|
+
const payload = await privacyFilterCache.status(this.kernel, {
|
|
8427
|
+
dtype: req.query.dtype
|
|
8428
|
+
})
|
|
8429
|
+
res.set("Cache-Control", "no-store")
|
|
8430
|
+
res.json(payload)
|
|
8431
|
+
}))
|
|
8432
|
+
this.app.post("/pinokio/privacy-filter/ensure", ex(async (req, res) => {
|
|
8433
|
+
if (!privacyFilterCache.isInstallRequestAllowed(req)) {
|
|
8434
|
+
res.status(403).json({ error: "Privacy filter cache install requires a same-origin request" })
|
|
8435
|
+
return
|
|
8436
|
+
}
|
|
8437
|
+
const payload = await privacyFilterCache.ensure(this.kernel, {
|
|
8438
|
+
dtype: req.query.dtype
|
|
8439
|
+
})
|
|
8440
|
+
res.set("Cache-Control", "no-store")
|
|
8441
|
+
res.json(payload)
|
|
8442
|
+
}))
|
|
8418
8443
|
this.app.get("/pinokio/logs/file", ex(logRedaction.createTopLevelLogFileHandler(this)))
|
|
8419
8444
|
this.app.get("/api/logs/stream", ex(async (req, res) => {
|
|
8420
8445
|
const workspace = typeof req.query.workspace === 'string' ? req.query.workspace.trim() : ''
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const { pipeline } = require('stream/promises')
|
|
4
|
+
const axios = require('axios')
|
|
5
|
+
|
|
6
|
+
const PRIVACY_FILTER_MODEL_ID = 'openai/privacy-filter'
|
|
7
|
+
const PRIVACY_FILTER_REVISION = '7ffa9a043d54d1be65afb281eddf0ffbe629385b'
|
|
8
|
+
const PRIVACY_FILTER_LOCAL_MODEL_PATH = `/pinokio/privacy-filter/models/${PRIVACY_FILTER_REVISION}/`
|
|
9
|
+
const PRIVACY_FILTER_SHARED_FILES = [
|
|
10
|
+
'config.json',
|
|
11
|
+
'tokenizer_config.json',
|
|
12
|
+
'tokenizer.json'
|
|
13
|
+
]
|
|
14
|
+
const PRIVACY_FILTER_DTYPE_FILES = {
|
|
15
|
+
q4f16: [
|
|
16
|
+
'onnx/model_q4f16.onnx',
|
|
17
|
+
'onnx/model_q4f16.onnx_data'
|
|
18
|
+
],
|
|
19
|
+
q4: [
|
|
20
|
+
'onnx/model_q4.onnx',
|
|
21
|
+
'onnx/model_q4.onnx_data'
|
|
22
|
+
],
|
|
23
|
+
q8: [
|
|
24
|
+
'onnx/model_quantized.onnx',
|
|
25
|
+
'onnx/model_quantized.onnx_data'
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
const PRIVACY_FILTER_ASSET_SET = new Set([
|
|
29
|
+
...PRIVACY_FILTER_SHARED_FILES,
|
|
30
|
+
...Object.values(PRIVACY_FILTER_DTYPE_FILES).flat()
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
const ensurePromises = new Map()
|
|
34
|
+
const installStates = new Map()
|
|
35
|
+
|
|
36
|
+
function normalizeDtype(value) {
|
|
37
|
+
const dtype = String(value || '').trim().toLowerCase()
|
|
38
|
+
return Object.prototype.hasOwnProperty.call(PRIVACY_FILTER_DTYPE_FILES, dtype) ? dtype : 'q8'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function cacheRoot(kernel) {
|
|
42
|
+
const home = kernel && typeof kernel.homedir === 'string' ? kernel.homedir : ''
|
|
43
|
+
if (!home.trim()) {
|
|
44
|
+
throw new Error('Pinokio home directory is required for privacy filter cache.')
|
|
45
|
+
}
|
|
46
|
+
return path.resolve(home, 'cache', 'privacy-filter', 'models')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function modelRoot(kernel) {
|
|
50
|
+
return path.resolve(cacheRoot(kernel), PRIVACY_FILTER_REVISION, 'openai', 'privacy-filter')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assetFilesForDtype(dtype) {
|
|
54
|
+
const normalized = normalizeDtype(dtype)
|
|
55
|
+
return [
|
|
56
|
+
...PRIVACY_FILTER_SHARED_FILES,
|
|
57
|
+
...PRIVACY_FILTER_DTYPE_FILES[normalized]
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function assertKnownAsset(relativePath) {
|
|
62
|
+
const normalized = String(relativePath || '').replace(/\\/g, '/')
|
|
63
|
+
if (!PRIVACY_FILTER_ASSET_SET.has(normalized)) {
|
|
64
|
+
throw new Error(`Unknown privacy filter asset: ${relativePath}`)
|
|
65
|
+
}
|
|
66
|
+
return normalized
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isInstallRequestAllowed(req) {
|
|
70
|
+
const fetchSite = String(req && typeof req.get === 'function' ? req.get('Sec-Fetch-Site') || '' : '').trim().toLowerCase()
|
|
71
|
+
if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none') {
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const origin = String(req && typeof req.get === 'function' ? req.get('Origin') || '' : '').trim()
|
|
76
|
+
if (!origin) {
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const host = String(req && typeof req.get === 'function' ? req.get('Host') || '' : '').trim().toLowerCase()
|
|
81
|
+
if (!host) {
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const parsed = new URL(origin)
|
|
87
|
+
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.host.toLowerCase() === host
|
|
88
|
+
} catch (_) {
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function assetPath(kernel, relativePath) {
|
|
94
|
+
const normalized = assertKnownAsset(relativePath)
|
|
95
|
+
return path.resolve(modelRoot(kernel), ...normalized.split('/'))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function ensureKey(kernel, dtype) {
|
|
99
|
+
return `${modelRoot(kernel)}:${normalizeDtype(dtype)}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function remoteAssetUrl(relativePath) {
|
|
103
|
+
const normalized = assertKnownAsset(relativePath)
|
|
104
|
+
return `https://huggingface.co/${PRIVACY_FILTER_MODEL_ID}/resolve/${PRIVACY_FILTER_REVISION}/${normalized}`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function copyInstallState(install) {
|
|
108
|
+
return install ? { ...install } : null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function cleanupStaleAssetTemps(target) {
|
|
112
|
+
const dir = path.dirname(target)
|
|
113
|
+
const base = path.basename(target)
|
|
114
|
+
const currentProcessPrefix = `${base}.${process.pid}.`
|
|
115
|
+
let entries
|
|
116
|
+
try {
|
|
117
|
+
entries = await fs.promises.readdir(dir, { withFileTypes: true })
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error && error.code === 'ENOENT') {
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
throw error
|
|
123
|
+
}
|
|
124
|
+
await Promise.all(entries
|
|
125
|
+
.filter((entry) => {
|
|
126
|
+
return entry.isFile() &&
|
|
127
|
+
entry.name.startsWith(`${base}.`) &&
|
|
128
|
+
entry.name.endsWith('.tmp') &&
|
|
129
|
+
!entry.name.startsWith(currentProcessPrefix)
|
|
130
|
+
})
|
|
131
|
+
.map((entry) => fs.promises.rm(path.resolve(dir, entry.name), { force: true })))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function fileStatus(kernel, relativePath) {
|
|
135
|
+
const target = assetPath(kernel, relativePath)
|
|
136
|
+
try {
|
|
137
|
+
const stats = await fs.promises.stat(target)
|
|
138
|
+
return {
|
|
139
|
+
path: relativePath,
|
|
140
|
+
exists: stats.isFile() && stats.size > 0,
|
|
141
|
+
size: stats.isFile() ? stats.size : 0
|
|
142
|
+
}
|
|
143
|
+
} catch (_) {
|
|
144
|
+
return {
|
|
145
|
+
path: relativePath,
|
|
146
|
+
exists: false,
|
|
147
|
+
size: 0
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function status(kernel, options = {}) {
|
|
153
|
+
const dtype = normalizeDtype(options.dtype)
|
|
154
|
+
const install = copyInstallState(installStates.get(ensureKey(kernel, dtype)))
|
|
155
|
+
const files = await Promise.all(assetFilesForDtype(dtype).map((file) => fileStatus(kernel, file)))
|
|
156
|
+
const missing = files.filter((file) => !file.exists).map((file) => file.path)
|
|
157
|
+
return {
|
|
158
|
+
model_id: PRIVACY_FILTER_MODEL_ID,
|
|
159
|
+
revision: PRIVACY_FILTER_REVISION,
|
|
160
|
+
dtype,
|
|
161
|
+
local_model_path: PRIVACY_FILTER_LOCAL_MODEL_PATH,
|
|
162
|
+
ready: missing.length === 0,
|
|
163
|
+
files,
|
|
164
|
+
missing,
|
|
165
|
+
missing_count: missing.length,
|
|
166
|
+
installing: Boolean(install),
|
|
167
|
+
install
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function downloadAsset(kernel, relativePath, onProgress) {
|
|
172
|
+
const target = assetPath(kernel, relativePath)
|
|
173
|
+
const existing = await fileStatus(kernel, relativePath)
|
|
174
|
+
if (existing.exists) {
|
|
175
|
+
return { path: relativePath, status: 'cached', size: existing.size }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await fs.promises.mkdir(path.dirname(target), { recursive: true })
|
|
179
|
+
await cleanupStaleAssetTemps(target)
|
|
180
|
+
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`
|
|
181
|
+
try {
|
|
182
|
+
const response = await axios.get(remoteAssetUrl(relativePath), {
|
|
183
|
+
responseType: 'stream',
|
|
184
|
+
maxRedirects: 5,
|
|
185
|
+
timeout: 0,
|
|
186
|
+
maxContentLength: Infinity,
|
|
187
|
+
maxBodyLength: Infinity,
|
|
188
|
+
validateStatus: (statusCode) => statusCode >= 200 && statusCode < 300
|
|
189
|
+
})
|
|
190
|
+
let loaded = 0
|
|
191
|
+
const total = Number(response.headers && response.headers['content-length']) || 0
|
|
192
|
+
const encoded = Boolean(response.headers && response.headers['content-encoding'])
|
|
193
|
+
if (typeof onProgress === 'function') {
|
|
194
|
+
onProgress({ loaded, total })
|
|
195
|
+
}
|
|
196
|
+
response.data.on('data', (chunk) => {
|
|
197
|
+
loaded += chunk && chunk.length ? chunk.length : 0
|
|
198
|
+
if (typeof onProgress === 'function') {
|
|
199
|
+
onProgress({ loaded, total })
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
await pipeline(response.data, fs.createWriteStream(tmp))
|
|
203
|
+
const stats = await fs.promises.stat(tmp)
|
|
204
|
+
if (!stats.isFile() || stats.size <= 0) {
|
|
205
|
+
throw new Error(`Downloaded empty privacy filter asset: ${relativePath}`)
|
|
206
|
+
}
|
|
207
|
+
if (total > 0 && !encoded && stats.size !== total) {
|
|
208
|
+
throw new Error(`Incomplete privacy filter asset: ${relativePath} (${stats.size}/${total} bytes)`)
|
|
209
|
+
}
|
|
210
|
+
await fs.promises.rename(tmp, target)
|
|
211
|
+
return { path: relativePath, status: 'downloaded', size: stats.size }
|
|
212
|
+
} catch (error) {
|
|
213
|
+
await fs.promises.rm(tmp, { force: true }).catch(() => {})
|
|
214
|
+
throw error
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function ensure(kernel, options = {}) {
|
|
219
|
+
const dtype = normalizeDtype(options.dtype)
|
|
220
|
+
const key = ensureKey(kernel, dtype)
|
|
221
|
+
if (ensurePromises.has(key)) {
|
|
222
|
+
return ensurePromises.get(key)
|
|
223
|
+
}
|
|
224
|
+
const promise = (async () => {
|
|
225
|
+
const files = assetFilesForDtype(dtype)
|
|
226
|
+
const install = {
|
|
227
|
+
dtype,
|
|
228
|
+
total_files: files.length,
|
|
229
|
+
completed_files: 0,
|
|
230
|
+
cached_files: 0,
|
|
231
|
+
downloaded_files: 0,
|
|
232
|
+
current_file: '',
|
|
233
|
+
current_loaded: 0,
|
|
234
|
+
current_total: 0,
|
|
235
|
+
started_at: new Date().toISOString(),
|
|
236
|
+
updated_at: new Date().toISOString()
|
|
237
|
+
}
|
|
238
|
+
installStates.set(key, install)
|
|
239
|
+
const results = []
|
|
240
|
+
for (let index = 0; index < files.length; index += 1) {
|
|
241
|
+
const file = files[index]
|
|
242
|
+
install.current_file = file
|
|
243
|
+
install.current_index = index + 1
|
|
244
|
+
install.current_loaded = 0
|
|
245
|
+
install.current_total = 0
|
|
246
|
+
install.updated_at = new Date().toISOString()
|
|
247
|
+
const result = await downloadAsset(kernel, file, ({ loaded, total }) => {
|
|
248
|
+
install.current_loaded = loaded
|
|
249
|
+
install.current_total = total
|
|
250
|
+
install.updated_at = new Date().toISOString()
|
|
251
|
+
})
|
|
252
|
+
results.push(result)
|
|
253
|
+
install.completed_files += 1
|
|
254
|
+
if (result.status === 'cached') {
|
|
255
|
+
install.cached_files += 1
|
|
256
|
+
} else if (result.status === 'downloaded') {
|
|
257
|
+
install.downloaded_files += 1
|
|
258
|
+
}
|
|
259
|
+
install.updated_at = new Date().toISOString()
|
|
260
|
+
}
|
|
261
|
+
const current = await status(kernel, { dtype })
|
|
262
|
+
return {
|
|
263
|
+
...current,
|
|
264
|
+
results,
|
|
265
|
+
downloaded: results.filter((result) => result.status === 'downloaded').length,
|
|
266
|
+
cached: results.filter((result) => result.status === 'cached').length
|
|
267
|
+
}
|
|
268
|
+
})().finally(() => {
|
|
269
|
+
ensurePromises.delete(key)
|
|
270
|
+
installStates.delete(key)
|
|
271
|
+
})
|
|
272
|
+
ensurePromises.set(key, promise)
|
|
273
|
+
return promise
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
module.exports = {
|
|
277
|
+
PRIVACY_FILTER_MODEL_ID,
|
|
278
|
+
PRIVACY_FILTER_REVISION,
|
|
279
|
+
PRIVACY_FILTER_LOCAL_MODEL_PATH,
|
|
280
|
+
normalizeDtype,
|
|
281
|
+
cacheRoot,
|
|
282
|
+
modelRoot,
|
|
283
|
+
isInstallRequestAllowed,
|
|
284
|
+
status,
|
|
285
|
+
ensure
|
|
286
|
+
}
|
|
@@ -387,10 +387,30 @@
|
|
|
387
387
|
const name = file && file.name ? file.name : 'file'
|
|
388
388
|
if (progress.type === 'runtime') {
|
|
389
389
|
this.setStatus(`Loading privacy filter locally (${progress.device}/${progress.dtype}) for ${name}.`)
|
|
390
|
-
} else if (progress.type === '
|
|
390
|
+
} else if (progress.type === 'cache-check') {
|
|
391
|
+
this.setStatus(`Checking local privacy filter cache for ${name}.`)
|
|
392
|
+
} else if (progress.type === 'cache-install') {
|
|
393
|
+
this.setStatus('Installing privacy filter locally. This can take a few minutes on first run.')
|
|
394
|
+
} else if (progress.type === 'cache-install-progress') {
|
|
395
|
+
const totalFiles = Number(progress.totalFiles) || 0
|
|
396
|
+
const fileIndex = Number(progress.fileIndex) || 0
|
|
397
|
+
const fileCount = totalFiles > 0 && fileIndex > 0 ? ` (${Math.min(fileIndex, totalFiles)} / ${totalFiles} files)` : ''
|
|
398
|
+
if (progress.total) {
|
|
399
|
+
this.setStatus(`Installing privacy filter locally${fileCount}: ${humanBytes(progress.loaded)} / ${humanBytes(progress.total)}.`)
|
|
400
|
+
} else {
|
|
401
|
+
this.setStatus(`Installing privacy filter locally${fileCount}.`)
|
|
402
|
+
}
|
|
403
|
+
} else if (progress.type === 'cache-ready') {
|
|
404
|
+
const downloaded = Number(progress.downloaded) || 0
|
|
405
|
+
this.setStatus(downloaded > 0 ? 'Privacy filter installed locally.' : `Privacy filter loaded from local cache for ${name}.`)
|
|
406
|
+
} else if (progress.type === 'cache-fallback') {
|
|
407
|
+
this.setStatus(progress.message || `Local privacy filter cache unavailable. Loading remote fallback for ${name}.`)
|
|
408
|
+
} else if (progress.type === 'asset-loading') {
|
|
409
|
+
this.setStatus('Loading privacy filter asset.')
|
|
410
|
+
} else if (progress.type === 'asset-progress') {
|
|
391
411
|
this.setStatus(progress.total
|
|
392
|
-
? `
|
|
393
|
-
: '
|
|
412
|
+
? `Loading privacy filter: ${humanBytes(progress.loaded)} / ${humanBytes(progress.total)}.`
|
|
413
|
+
: 'Loading privacy filter.')
|
|
394
414
|
} else if (progress.type === 'chunk') {
|
|
395
415
|
const total = Number(progress.total) || 0
|
|
396
416
|
const done = Number(progress.done) || 0
|
|
@@ -472,6 +492,9 @@
|
|
|
472
492
|
this.setStatus(error && error.message ? error.message : 'Privacy filtering failed.', true)
|
|
473
493
|
} finally {
|
|
474
494
|
this.setBusy(false)
|
|
495
|
+
if (this.files.length && this.filesEl && this.filesEl.children.length) {
|
|
496
|
+
this.renderFileList()
|
|
497
|
+
}
|
|
475
498
|
this.updateCount()
|
|
476
499
|
}
|
|
477
500
|
}
|
package/server/public/logs.js
CHANGED
|
@@ -207,7 +207,15 @@
|
|
|
207
207
|
return this.worker
|
|
208
208
|
}
|
|
209
209
|
handleMessage(message) {
|
|
210
|
-
if (message.
|
|
210
|
+
if (!message.id && (
|
|
211
|
+
message.type === 'asset-loading' ||
|
|
212
|
+
message.type === 'asset-progress' ||
|
|
213
|
+
message.type === 'cache-check' ||
|
|
214
|
+
message.type === 'cache-install' ||
|
|
215
|
+
message.type === 'cache-install-progress' ||
|
|
216
|
+
message.type === 'cache-ready' ||
|
|
217
|
+
message.type === 'cache-fallback'
|
|
218
|
+
)) {
|
|
211
219
|
for (const pending of this.pending.values()) {
|
|
212
220
|
pending.onProgress(message)
|
|
213
221
|
}
|
|
@@ -221,6 +229,16 @@
|
|
|
221
229
|
pending.onProgress(message)
|
|
222
230
|
} else if (message.type === 'fallback') {
|
|
223
231
|
pending.onProgress(message)
|
|
232
|
+
} else if (
|
|
233
|
+
message.type === 'asset-loading' ||
|
|
234
|
+
message.type === 'asset-progress' ||
|
|
235
|
+
message.type === 'cache-check' ||
|
|
236
|
+
message.type === 'cache-install' ||
|
|
237
|
+
message.type === 'cache-install-progress' ||
|
|
238
|
+
message.type === 'cache-ready' ||
|
|
239
|
+
message.type === 'cache-fallback'
|
|
240
|
+
) {
|
|
241
|
+
pending.onProgress(message)
|
|
224
242
|
} else if (message.type === 'result') {
|
|
225
243
|
this.pending.delete(message.id)
|
|
226
244
|
pending.resolve(message)
|
|
@@ -2109,15 +2127,37 @@
|
|
|
2109
2127
|
return
|
|
2110
2128
|
}
|
|
2111
2129
|
if (progress.type === 'runtime') {
|
|
2112
|
-
this.setStatus(`
|
|
2130
|
+
this.setStatus(`Preparing OpenAI Privacy Filter locally (${progress.device}/${progress.dtype}).`)
|
|
2131
|
+
} else if (progress.type === 'cache-check') {
|
|
2132
|
+
this.setStatus('Checking local privacy filter cache.')
|
|
2133
|
+
} else if (progress.type === 'cache-install') {
|
|
2134
|
+
this.setStatus('Installing privacy filter locally. This can take a few minutes on first run.')
|
|
2135
|
+
} else if (progress.type === 'cache-install-progress') {
|
|
2136
|
+
const fileLabel = progress.file ? ` ${progress.file}` : ''
|
|
2137
|
+
const totalFiles = Number(progress.totalFiles) || 0
|
|
2138
|
+
const fileIndex = Number(progress.fileIndex) || 0
|
|
2139
|
+
const fileCount = totalFiles > 0 && fileIndex > 0 ? ` (${Math.min(fileIndex, totalFiles)} / ${totalFiles} files)` : ''
|
|
2140
|
+
if (progress.total) {
|
|
2141
|
+
this.setStatus(`Installing privacy filter locally${fileCount}${fileLabel}: ${humanBytes(progress.loaded)} / ${humanBytes(progress.total)}.`)
|
|
2142
|
+
} else {
|
|
2143
|
+
this.setStatus(`Installing privacy filter locally${fileCount}${fileLabel}.`)
|
|
2144
|
+
}
|
|
2145
|
+
} else if (progress.type === 'cache-ready') {
|
|
2146
|
+
const downloaded = Number(progress.downloaded) || 0
|
|
2147
|
+
this.setStatus(downloaded > 0 ? 'Privacy filter installed locally.' : 'Privacy filter loaded from local cache.')
|
|
2148
|
+
} else if (progress.type === 'cache-fallback') {
|
|
2149
|
+
this.setStatus(progress.message || 'Local privacy filter cache unavailable. Loading remote fallback.')
|
|
2113
2150
|
} else if (progress.type === 'fallback') {
|
|
2114
2151
|
this.setStatus(progress.message || `Retrying privacy filtering locally with ${progress.device}/${progress.dtype}.`)
|
|
2115
|
-
} else if (progress.type === '
|
|
2152
|
+
} else if (progress.type === 'asset-loading') {
|
|
2153
|
+
const fileLabel = progress.file ? ` ${progress.file}` : ''
|
|
2154
|
+
this.setStatus(`Loading privacy filter asset${fileLabel}.`)
|
|
2155
|
+
} else if (progress.type === 'asset-progress') {
|
|
2116
2156
|
const fileLabel = progress.file ? ` ${progress.file}` : ''
|
|
2117
2157
|
if (progress.total) {
|
|
2118
|
-
this.setStatus(`
|
|
2158
|
+
this.setStatus(`Loading privacy filter${fileLabel}: ${humanBytes(progress.loaded)} / ${humanBytes(progress.total)}.`)
|
|
2119
2159
|
} else {
|
|
2120
|
-
this.setStatus(`
|
|
2160
|
+
this.setStatus(`Loading privacy filter${fileLabel}.`)
|
|
2121
2161
|
}
|
|
2122
2162
|
} else if (progress.type === 'chunk') {
|
|
2123
2163
|
const total = Number(progress.total) || 0
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const TRANSFORMERS_URL = 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.2.0/+esm'
|
|
2
2
|
const MODEL_ID = 'openai/privacy-filter'
|
|
3
|
+
const MODEL_REVISION = '7ffa9a043d54d1be65afb281eddf0ffbe629385b'
|
|
4
|
+
const LOCAL_MODEL_PATH = `/pinokio/privacy-filter/models/${MODEL_REVISION}/`
|
|
3
5
|
const MAX_CHUNK_CHARS = 1800
|
|
4
6
|
const CHUNK_OVERLAP_CHARS = 120
|
|
5
7
|
const URL_COMPONENT_LABELS = new Set([
|
|
@@ -15,11 +17,52 @@ let pipelinePromise = null
|
|
|
15
17
|
let pipelineKey = ''
|
|
16
18
|
let activeDevice = 'webgpu'
|
|
17
19
|
let activeDtype = 'q4f16'
|
|
20
|
+
const localCachePromises = new Map()
|
|
18
21
|
|
|
19
22
|
const post = (message) => {
|
|
20
23
|
self.postMessage(message)
|
|
21
24
|
}
|
|
22
25
|
|
|
26
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
27
|
+
|
|
28
|
+
const cacheUrl = (pathname, params) => {
|
|
29
|
+
const url = new URL(pathname, self.location.origin)
|
|
30
|
+
Object.entries(params || {}).forEach(([key, value]) => {
|
|
31
|
+
if (value != null && value !== '') {
|
|
32
|
+
url.searchParams.set(key, value)
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
return url.toString()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const fetchCacheStatus = async (dtype) => {
|
|
39
|
+
const response = await fetch(cacheUrl('/pinokio/privacy-filter/status', { dtype }), {
|
|
40
|
+
headers: { 'Accept': 'application/json' }
|
|
41
|
+
})
|
|
42
|
+
return response.ok ? response.json() : null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const postCacheInstallProgress = ({ id, device, dtype, status }) => {
|
|
46
|
+
const install = status && status.install
|
|
47
|
+
if (!install) {
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
post({
|
|
51
|
+
type: 'cache-install-progress',
|
|
52
|
+
id,
|
|
53
|
+
device,
|
|
54
|
+
dtype,
|
|
55
|
+
file: install.current_file || '',
|
|
56
|
+
fileIndex: Number(install.current_index) || 0,
|
|
57
|
+
doneFiles: Number(install.completed_files) || 0,
|
|
58
|
+
totalFiles: Number(install.total_files) || 0,
|
|
59
|
+
loaded: Number(install.current_loaded) || 0,
|
|
60
|
+
total: Number(install.current_total) || 0,
|
|
61
|
+
cachedFiles: Number(install.cached_files) || 0,
|
|
62
|
+
downloadedFiles: Number(install.downloaded_files) || 0
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
23
66
|
const normalizeLabel = (value) => {
|
|
24
67
|
const label = String(value || 'private')
|
|
25
68
|
.replace(/^[bieos]-/i, '')
|
|
@@ -272,8 +315,9 @@ const loadTransformers = async () => {
|
|
|
272
315
|
if (!transformersPromise) {
|
|
273
316
|
transformersPromise = import(TRANSFORMERS_URL).then((mod) => {
|
|
274
317
|
if (mod.env) {
|
|
275
|
-
mod.env.allowLocalModels =
|
|
318
|
+
mod.env.allowLocalModels = true
|
|
276
319
|
mod.env.allowRemoteModels = true
|
|
320
|
+
mod.env.localModelPath = LOCAL_MODEL_PATH
|
|
277
321
|
mod.env.useBrowserCache = true
|
|
278
322
|
mod.env.useWasmCache = true
|
|
279
323
|
mod.env.cacheKey = 'pinokio-privacy-filter-cache'
|
|
@@ -284,7 +328,93 @@ const loadTransformers = async () => {
|
|
|
284
328
|
return transformersPromise
|
|
285
329
|
}
|
|
286
330
|
|
|
287
|
-
const
|
|
331
|
+
const ensureLocalCache = async ({ id, device, dtype }) => {
|
|
332
|
+
const key = dtype || activeDtype
|
|
333
|
+
if (localCachePromises.has(key)) {
|
|
334
|
+
return localCachePromises.get(key)
|
|
335
|
+
}
|
|
336
|
+
const promise = (async () => {
|
|
337
|
+
post({ type: 'cache-check', id, device, dtype })
|
|
338
|
+
let status = null
|
|
339
|
+
try {
|
|
340
|
+
status = await fetchCacheStatus(dtype)
|
|
341
|
+
} catch (_) {}
|
|
342
|
+
if (status && status.ready) {
|
|
343
|
+
post({ type: 'cache-ready', id, device, dtype, downloaded: 0, cached: Array.isArray(status.files) ? status.files.length : 0 })
|
|
344
|
+
return true
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
post({
|
|
348
|
+
type: 'cache-install',
|
|
349
|
+
id,
|
|
350
|
+
device,
|
|
351
|
+
dtype,
|
|
352
|
+
missing: status && Array.isArray(status.missing) ? status.missing : [],
|
|
353
|
+
missingCount: status && Number.isFinite(status.missing_count) ? status.missing_count : 0
|
|
354
|
+
})
|
|
355
|
+
const ensurePromise = fetch(cacheUrl('/pinokio/privacy-filter/ensure', { dtype }), {
|
|
356
|
+
method: 'POST',
|
|
357
|
+
headers: { 'Accept': 'application/json' }
|
|
358
|
+
}).then(async (ensureResponse) => {
|
|
359
|
+
if (!ensureResponse.ok) {
|
|
360
|
+
const message = await ensureResponse.text().catch(() => '')
|
|
361
|
+
throw new Error(message || `HTTP ${ensureResponse.status}`)
|
|
362
|
+
}
|
|
363
|
+
return ensureResponse.json()
|
|
364
|
+
})
|
|
365
|
+
const settledEnsure = ensurePromise.then(
|
|
366
|
+
(value) => ({ done: true, value }),
|
|
367
|
+
(error) => ({ done: true, error })
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
let payload = null
|
|
371
|
+
while (!payload) {
|
|
372
|
+
const result = await Promise.race([
|
|
373
|
+
settledEnsure,
|
|
374
|
+
delay(1000).then(() => ({ done: false }))
|
|
375
|
+
])
|
|
376
|
+
if (result.done) {
|
|
377
|
+
if (result.error) {
|
|
378
|
+
throw result.error
|
|
379
|
+
}
|
|
380
|
+
payload = result.value
|
|
381
|
+
break
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
status = await fetchCacheStatus(dtype)
|
|
385
|
+
if (status && status.installing) {
|
|
386
|
+
postCacheInstallProgress({ id, device, dtype, status })
|
|
387
|
+
}
|
|
388
|
+
} catch (_) {}
|
|
389
|
+
}
|
|
390
|
+
if (!payload || payload.ready !== true) {
|
|
391
|
+
throw new Error('Privacy filter cache is incomplete.')
|
|
392
|
+
}
|
|
393
|
+
post({
|
|
394
|
+
type: 'cache-ready',
|
|
395
|
+
id,
|
|
396
|
+
device,
|
|
397
|
+
dtype,
|
|
398
|
+
downloaded: Number(payload.downloaded) || 0,
|
|
399
|
+
cached: Number(payload.cached) || 0
|
|
400
|
+
})
|
|
401
|
+
return true
|
|
402
|
+
})().catch((error) => {
|
|
403
|
+
localCachePromises.delete(key)
|
|
404
|
+
post({
|
|
405
|
+
type: 'cache-fallback',
|
|
406
|
+
id,
|
|
407
|
+
device,
|
|
408
|
+
dtype,
|
|
409
|
+
message: `Local privacy filter cache unavailable. Loading from remote cache fallback. ${error && error.message ? error.message : ''}`.trim()
|
|
410
|
+
})
|
|
411
|
+
return false
|
|
412
|
+
})
|
|
413
|
+
localCachePromises.set(key, promise)
|
|
414
|
+
return promise
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const getPipeline = async (id, device, dtype) => {
|
|
288
418
|
const nextDevice = device || activeDevice
|
|
289
419
|
const nextDtype = dtype || activeDtype
|
|
290
420
|
const nextKey = `${nextDevice}:${nextDtype}`
|
|
@@ -298,14 +428,24 @@ const getPipeline = async (device, dtype) => {
|
|
|
298
428
|
return pipeline('token-classification', MODEL_ID, {
|
|
299
429
|
device: activeDevice,
|
|
300
430
|
dtype: activeDtype,
|
|
431
|
+
revision: MODEL_REVISION,
|
|
301
432
|
progress_callback: (progress) => {
|
|
302
|
-
if (progress
|
|
433
|
+
if (!progress || !progress.status) {
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
if (progress.status === 'download') {
|
|
437
|
+
post({
|
|
438
|
+
type: 'asset-loading',
|
|
439
|
+
file: progress.file || ''
|
|
440
|
+
})
|
|
441
|
+
} else if (progress.status === 'progress' || progress.status === 'progress_total') {
|
|
303
442
|
post({
|
|
304
|
-
type: '
|
|
443
|
+
type: 'asset-progress',
|
|
305
444
|
file: progress.file || '',
|
|
306
445
|
loaded: progress.loaded || 0,
|
|
307
446
|
total: progress.total || 0,
|
|
308
|
-
progress: progress.progress || 0
|
|
447
|
+
progress: progress.progress || 0,
|
|
448
|
+
aggregate: progress.status === 'progress_total'
|
|
309
449
|
})
|
|
310
450
|
}
|
|
311
451
|
}
|
|
@@ -340,7 +480,8 @@ const filterText = async ({ id, text, device, dtype }) => {
|
|
|
340
480
|
let classifier = null
|
|
341
481
|
let entities = []
|
|
342
482
|
try {
|
|
343
|
-
|
|
483
|
+
await ensureLocalCache({ id, device: requestedDevice, dtype: requestedDtype })
|
|
484
|
+
classifier = await getPipeline(id, requestedDevice, requestedDtype)
|
|
344
485
|
entities = await classifyChunks({ classifier, chunks, id })
|
|
345
486
|
} catch (error) {
|
|
346
487
|
if (requestedDevice === 'wasm') {
|
|
@@ -357,7 +498,8 @@ const filterText = async ({ id, text, device, dtype }) => {
|
|
|
357
498
|
dtype: requestedDtype,
|
|
358
499
|
message: 'WebGPU privacy filtering failed. Retrying locally with WASM.'
|
|
359
500
|
})
|
|
360
|
-
|
|
501
|
+
await ensureLocalCache({ id, device: requestedDevice, dtype: requestedDtype })
|
|
502
|
+
classifier = await getPipeline(id, requestedDevice, requestedDtype)
|
|
361
503
|
entities = await classifyChunks({ classifier, chunks, id })
|
|
362
504
|
}
|
|
363
505
|
const merged = mergeEntities(prepareMaskEntities(reportText, entities))
|
package/test/logs-ask-ai.test.js
CHANGED
|
@@ -255,6 +255,19 @@ async function createRawLogsDom(options = {}) {
|
|
|
255
255
|
if (tokenMatch) {
|
|
256
256
|
addItem("private_key", tokenMatch[0])
|
|
257
257
|
}
|
|
258
|
+
setTimeout(() => {
|
|
259
|
+
const listener = this.listeners.get("message")
|
|
260
|
+
if (listener) {
|
|
261
|
+
for (const progress of (options.workerProgress || [])) {
|
|
262
|
+
listener({
|
|
263
|
+
data: {
|
|
264
|
+
id: message.id,
|
|
265
|
+
...progress
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}, 0)
|
|
258
271
|
setTimeout(() => {
|
|
259
272
|
const listener = this.listeners.get("message")
|
|
260
273
|
if (listener) {
|
|
@@ -268,7 +281,7 @@ async function createRawLogsDom(options = {}) {
|
|
|
268
281
|
}
|
|
269
282
|
})
|
|
270
283
|
}
|
|
271
|
-
}, 0)
|
|
284
|
+
}, options.workerResultDelayMs || 0)
|
|
272
285
|
}
|
|
273
286
|
}
|
|
274
287
|
window.fetch = async (url, options = {}) => {
|
|
@@ -476,6 +489,10 @@ test("raw logs redaction sends reviewed top-level overrides when generating zip"
|
|
|
476
489
|
document.getElementById("logs-top-redaction-count").textContent.includes("masked")
|
|
477
490
|
}, "top-level redaction review")
|
|
478
491
|
|
|
492
|
+
await waitFor(() => document.getElementById("logs-top-redaction-status").textContent.includes("Report redacted"), "top-level redaction complete")
|
|
493
|
+
const fileModes = Array.from(document.querySelectorAll(".logs-top-redaction-mode"))
|
|
494
|
+
assert.equal(fileModes.length > 0, true)
|
|
495
|
+
assert.equal(fileModes.every((select) => select.disabled === false), true)
|
|
479
496
|
assert.match(document.getElementById("logs-top-redaction-list").textContent, /private_key/)
|
|
480
497
|
|
|
481
498
|
document.getElementById("logs-redaction-collapse").click()
|
|
@@ -497,6 +514,48 @@ test("raw logs redaction sends reviewed top-level overrides when generating zip"
|
|
|
497
514
|
assert.equal(systemOverride.text.includes("sk-proj-123456789012345678901234"), false)
|
|
498
515
|
})
|
|
499
516
|
|
|
517
|
+
test("raw logs redaction describes model asset progress as loading", async () => {
|
|
518
|
+
const { dom } = await createRawLogsDom({
|
|
519
|
+
workerResultDelayMs: 50,
|
|
520
|
+
workerProgress: [{
|
|
521
|
+
id: null,
|
|
522
|
+
type: "asset-progress",
|
|
523
|
+
loaded: 25,
|
|
524
|
+
total: 100
|
|
525
|
+
}]
|
|
526
|
+
})
|
|
527
|
+
const { document } = dom.window
|
|
528
|
+
|
|
529
|
+
document.getElementById("logs-redact-top-level").click()
|
|
530
|
+
|
|
531
|
+
await waitFor(() => /Loading privacy filter/.test(document.getElementById("logs-top-redaction-status").textContent), "privacy filter loading status")
|
|
532
|
+
assert.equal(/Downloading privacy filter/.test(document.getElementById("logs-top-redaction-status").textContent), false)
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
test("raw logs redaction shows privacy filter install progress", async () => {
|
|
536
|
+
const { dom } = await createRawLogsDom({
|
|
537
|
+
workerResultDelayMs: 50,
|
|
538
|
+
workerProgress: [{
|
|
539
|
+
id: null,
|
|
540
|
+
type: "cache-install-progress",
|
|
541
|
+
fileIndex: 5,
|
|
542
|
+
totalFiles: 5,
|
|
543
|
+
loaded: 512,
|
|
544
|
+
total: 1024
|
|
545
|
+
}]
|
|
546
|
+
})
|
|
547
|
+
const { document } = dom.window
|
|
548
|
+
|
|
549
|
+
document.getElementById("logs-redact-top-level").click()
|
|
550
|
+
|
|
551
|
+
await waitFor(() => {
|
|
552
|
+
const status = document.getElementById("logs-top-redaction-status").textContent
|
|
553
|
+
return /Installing privacy filter locally/.test(status) &&
|
|
554
|
+
/5 \/ 5 files/.test(status) &&
|
|
555
|
+
/512 B \/ 1\.0 KB/.test(status)
|
|
556
|
+
}, "privacy filter install progress status")
|
|
557
|
+
})
|
|
558
|
+
|
|
500
559
|
test("raw log report drawer opens with file choices before redaction", async () => {
|
|
501
560
|
const { dom, archiveRequests } = await createRawLogsDom()
|
|
502
561
|
const { document } = dom.window
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
const assert = require('node:assert/strict')
|
|
2
|
+
const fs = require('node:fs/promises')
|
|
3
|
+
const os = require('node:os')
|
|
4
|
+
const path = require('node:path')
|
|
5
|
+
const { PassThrough, Readable } = require('node:stream')
|
|
6
|
+
const test = require('node:test')
|
|
7
|
+
|
|
8
|
+
const cacheModulePath = path.resolve(__dirname, '..', 'server', 'lib', 'privacy_filter_cache.js')
|
|
9
|
+
const axiosModulePath = require.resolve('axios')
|
|
10
|
+
|
|
11
|
+
function loadCacheWithAxiosMock(mockAxios) {
|
|
12
|
+
const originalAxios = require.cache[axiosModulePath]
|
|
13
|
+
delete require.cache[cacheModulePath]
|
|
14
|
+
require.cache[axiosModulePath] = {
|
|
15
|
+
id: axiosModulePath,
|
|
16
|
+
filename: axiosModulePath,
|
|
17
|
+
loaded: true,
|
|
18
|
+
exports: mockAxios
|
|
19
|
+
}
|
|
20
|
+
const mod = require(cacheModulePath)
|
|
21
|
+
return {
|
|
22
|
+
mod,
|
|
23
|
+
restore() {
|
|
24
|
+
delete require.cache[cacheModulePath]
|
|
25
|
+
if (originalAxios) {
|
|
26
|
+
require.cache[axiosModulePath] = originalAxios
|
|
27
|
+
} else {
|
|
28
|
+
delete require.cache[axiosModulePath]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fakeRequest(headers) {
|
|
35
|
+
const normalized = {}
|
|
36
|
+
for (const [key, value] of Object.entries(headers || {})) {
|
|
37
|
+
normalized[key.toLowerCase()] = value
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
get(name) {
|
|
41
|
+
return normalized[String(name).toLowerCase()]
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function waitForValue(fn, label) {
|
|
47
|
+
const start = Date.now()
|
|
48
|
+
while (Date.now() - start < 1000) {
|
|
49
|
+
const value = await fn()
|
|
50
|
+
if (value) {
|
|
51
|
+
return value
|
|
52
|
+
}
|
|
53
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
54
|
+
}
|
|
55
|
+
assert.fail(`Timed out waiting for ${label}`)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test('privacy filter cache installs requested dtype files under local model path', async () => {
|
|
59
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-privacy-filter-cache-'))
|
|
60
|
+
const requests = []
|
|
61
|
+
const { mod, restore } = loadCacheWithAxiosMock({
|
|
62
|
+
get: async (url) => {
|
|
63
|
+
requests.push(url)
|
|
64
|
+
return {
|
|
65
|
+
data: Readable.from([Buffer.from(`asset:${url}`)])
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const kernel = { homedir: root }
|
|
72
|
+
const staleRoot = path.resolve(root, 'cache', 'privacy-filter', 'models', 'openai', 'privacy-filter')
|
|
73
|
+
await fs.mkdir(staleRoot, { recursive: true })
|
|
74
|
+
await fs.writeFile(path.resolve(staleRoot, 'config.json'), 'stale')
|
|
75
|
+
|
|
76
|
+
const before = await mod.status(kernel, { dtype: 'q4f16' })
|
|
77
|
+
assert.equal(before.ready, false)
|
|
78
|
+
assert.deepEqual(before.missing, [
|
|
79
|
+
'config.json',
|
|
80
|
+
'tokenizer_config.json',
|
|
81
|
+
'tokenizer.json',
|
|
82
|
+
'onnx/model_q4f16.onnx',
|
|
83
|
+
'onnx/model_q4f16.onnx_data'
|
|
84
|
+
])
|
|
85
|
+
|
|
86
|
+
const ensured = await mod.ensure(kernel, { dtype: 'q4f16' })
|
|
87
|
+
assert.equal(ensured.ready, true)
|
|
88
|
+
assert.equal(ensured.downloaded, 5)
|
|
89
|
+
assert.equal(ensured.local_model_path, `/pinokio/privacy-filter/models/${mod.PRIVACY_FILTER_REVISION}/`)
|
|
90
|
+
assert.equal(requests.length, 5)
|
|
91
|
+
assert.ok(requests.every((url) => url.includes(mod.PRIVACY_FILTER_REVISION)))
|
|
92
|
+
|
|
93
|
+
const modelRoot = mod.modelRoot(kernel)
|
|
94
|
+
assert.equal(modelRoot.includes(mod.PRIVACY_FILTER_REVISION), true)
|
|
95
|
+
const config = await fs.readFile(path.resolve(modelRoot, 'config.json'), 'utf8')
|
|
96
|
+
assert.match(config, /asset:https:\/\/huggingface\.co\/openai\/privacy-filter\/resolve\//)
|
|
97
|
+
|
|
98
|
+
const after = await mod.ensure(kernel, { dtype: 'q4f16' })
|
|
99
|
+
assert.equal(after.ready, true)
|
|
100
|
+
assert.equal(after.downloaded, 0)
|
|
101
|
+
assert.equal(after.cached, 5)
|
|
102
|
+
assert.equal(requests.length, 5)
|
|
103
|
+
} finally {
|
|
104
|
+
restore()
|
|
105
|
+
await fs.rm(root, { recursive: true, force: true })
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('privacy filter cache install endpoint accepts only same-origin browser writes', async () => {
|
|
110
|
+
const { mod, restore } = loadCacheWithAxiosMock({ get: async () => ({ data: Readable.from(['']) }) })
|
|
111
|
+
try {
|
|
112
|
+
assert.equal(mod.isInstallRequestAllowed(fakeRequest({})), true)
|
|
113
|
+
assert.equal(mod.isInstallRequestAllowed(fakeRequest({
|
|
114
|
+
Host: 'localhost:42000',
|
|
115
|
+
Origin: 'http://localhost:42000',
|
|
116
|
+
'Sec-Fetch-Site': 'same-origin'
|
|
117
|
+
})), true)
|
|
118
|
+
assert.equal(mod.isInstallRequestAllowed(fakeRequest({
|
|
119
|
+
Host: 'localhost:42000',
|
|
120
|
+
Origin: 'http://evil.example',
|
|
121
|
+
'Sec-Fetch-Site': 'cross-site'
|
|
122
|
+
})), false)
|
|
123
|
+
assert.equal(mod.isInstallRequestAllowed(fakeRequest({
|
|
124
|
+
Host: 'localhost:42000',
|
|
125
|
+
Origin: 'http://localhost:5173',
|
|
126
|
+
'Sec-Fetch-Site': 'same-site'
|
|
127
|
+
})), false)
|
|
128
|
+
assert.equal(mod.isInstallRequestAllowed(fakeRequest({
|
|
129
|
+
Host: 'localhost:42000',
|
|
130
|
+
Origin: 'not a url',
|
|
131
|
+
'Sec-Fetch-Site': 'same-origin'
|
|
132
|
+
})), false)
|
|
133
|
+
} finally {
|
|
134
|
+
restore()
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('privacy filter cache requires an explicit Pinokio home', async () => {
|
|
139
|
+
const { mod, restore } = loadCacheWithAxiosMock({
|
|
140
|
+
get: async () => {
|
|
141
|
+
throw new Error('download should not start')
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
try {
|
|
145
|
+
assert.throws(() => mod.cacheRoot({}), /Pinokio home directory is required/)
|
|
146
|
+
await assert.rejects(() => mod.status({}, { dtype: 'q8' }), /Pinokio home directory is required/)
|
|
147
|
+
await assert.rejects(() => mod.ensure({}, { dtype: 'q8' }), /Pinokio home directory is required/)
|
|
148
|
+
} finally {
|
|
149
|
+
restore()
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('privacy filter cache removes stale temp files before retrying an asset', async () => {
|
|
154
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-privacy-filter-cache-temp-'))
|
|
155
|
+
const { mod, restore } = loadCacheWithAxiosMock({
|
|
156
|
+
get: async (url) => ({
|
|
157
|
+
data: Readable.from([Buffer.from(`asset:${url}`)])
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const kernel = { homedir: root }
|
|
163
|
+
const target = path.resolve(mod.modelRoot(kernel), 'config.json')
|
|
164
|
+
const staleTemp = `${target}.999999.1.tmp`
|
|
165
|
+
await fs.mkdir(path.dirname(target), { recursive: true })
|
|
166
|
+
await fs.writeFile(staleTemp, 'partial')
|
|
167
|
+
|
|
168
|
+
await mod.ensure(kernel, { dtype: 'q8' })
|
|
169
|
+
|
|
170
|
+
await assert.rejects(() => fs.stat(staleTemp), /ENOENT/)
|
|
171
|
+
const installed = await fs.readFile(target, 'utf8')
|
|
172
|
+
assert.match(installed, /asset:https:\/\/huggingface\.co\/openai\/privacy-filter\/resolve\//)
|
|
173
|
+
} finally {
|
|
174
|
+
restore()
|
|
175
|
+
await fs.rm(root, { recursive: true, force: true })
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('privacy filter cache rejects truncated downloads when content length is available', async () => {
|
|
180
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-privacy-filter-cache-truncated-'))
|
|
181
|
+
const { mod, restore } = loadCacheWithAxiosMock({
|
|
182
|
+
get: async () => ({
|
|
183
|
+
headers: {
|
|
184
|
+
'content-length': '8'
|
|
185
|
+
},
|
|
186
|
+
data: Readable.from([Buffer.from('short')])
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const kernel = { homedir: root }
|
|
192
|
+
const target = path.resolve(mod.modelRoot(kernel), 'config.json')
|
|
193
|
+
await assert.rejects(
|
|
194
|
+
() => mod.ensure(kernel, { dtype: 'q8' }),
|
|
195
|
+
/Incomplete privacy filter asset: config\.json/
|
|
196
|
+
)
|
|
197
|
+
await assert.rejects(() => fs.stat(target), /ENOENT/)
|
|
198
|
+
const dir = path.dirname(target)
|
|
199
|
+
const entries = await fs.readdir(dir)
|
|
200
|
+
assert.equal(entries.some((entry) => entry.endsWith('.tmp')), false)
|
|
201
|
+
} finally {
|
|
202
|
+
restore()
|
|
203
|
+
await fs.rm(root, { recursive: true, force: true })
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('privacy filter cache exposes in-progress install status', async () => {
|
|
208
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-privacy-filter-cache-progress-'))
|
|
209
|
+
const { mod, restore } = loadCacheWithAxiosMock({
|
|
210
|
+
get: async () => {
|
|
211
|
+
const stream = new PassThrough()
|
|
212
|
+
setTimeout(() => stream.write(Buffer.alloc(512)), 20)
|
|
213
|
+
setTimeout(() => stream.end(Buffer.alloc(512)), 80)
|
|
214
|
+
return {
|
|
215
|
+
headers: {
|
|
216
|
+
'content-length': '1024'
|
|
217
|
+
},
|
|
218
|
+
data: stream
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const kernel = { homedir: root }
|
|
225
|
+
const ensurePromise = mod.ensure(kernel, { dtype: 'q8' })
|
|
226
|
+
const installing = await waitForValue(async () => {
|
|
227
|
+
const current = await mod.status(kernel, { dtype: 'q8' })
|
|
228
|
+
return current.installing && current.install && current.install.current_file && current.install.current_total === 1024 ? current : null
|
|
229
|
+
}, 'privacy filter install progress')
|
|
230
|
+
|
|
231
|
+
assert.equal(installing.install.total_files, 5)
|
|
232
|
+
assert.equal(installing.install.current_file, 'config.json')
|
|
233
|
+
assert.equal(installing.install.current_total, 1024)
|
|
234
|
+
|
|
235
|
+
const ensured = await ensurePromise
|
|
236
|
+
assert.equal(ensured.ready, true)
|
|
237
|
+
assert.equal(ensured.downloaded, 5)
|
|
238
|
+
} finally {
|
|
239
|
+
restore()
|
|
240
|
+
await fs.rm(root, { recursive: true, force: true })
|
|
241
|
+
}
|
|
242
|
+
})
|