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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "7.5.42",
3
+ "version": "7.5.44",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
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 === 'download') {
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
- ? `Downloading privacy filter: ${humanBytes(progress.loaded)} / ${humanBytes(progress.total)}.`
393
- : 'Downloading privacy filter.')
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
  }
@@ -207,7 +207,15 @@
207
207
  return this.worker
208
208
  }
209
209
  handleMessage(message) {
210
- if (message.type === 'download') {
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(`Loading OpenAI Privacy Filter locally (${progress.device}/${progress.dtype}). First run downloads and caches the model.`)
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 === 'download') {
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(`Downloading privacy filter${fileLabel}: ${humanBytes(progress.loaded)} / ${humanBytes(progress.total)}. Cached for future reports.`)
2158
+ this.setStatus(`Loading privacy filter${fileLabel}: ${humanBytes(progress.loaded)} / ${humanBytes(progress.total)}.`)
2119
2159
  } else {
2120
- this.setStatus(`Downloading privacy filter${fileLabel}. Cached for future reports.`)
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 = false
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 getPipeline = async (device, dtype) => {
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 && progress.status === '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: 'download',
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
- classifier = await getPipeline(requestedDevice, requestedDtype)
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
- classifier = await getPipeline(requestedDevice, requestedDtype)
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))
@@ -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
+ })