@bendyline/gezel-inference 1.0.0 → 1.0.2
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/README.md +90 -8
- package/dist/cache/adapter.d.ts +7 -0
- package/dist/cache/controller.d.ts +12 -0
- package/dist/cache/controller.js +24 -0
- package/dist/catalog/chat-models/bt/btl4-35b-q4/manifest.json +1 -0
- package/dist/catalog/chat-models/gl/glm-5.2-754b-q2/manifest.json +1 -0
- package/dist/catalog/chat-models/index.json +1 -1
- package/dist/catalog/chat-models/lf/lfm2.5-24b-a2b-q4/manifest.json +122 -0
- package/dist/catalog/chat-models/lf/lfm2.5-24b-a2b-q4/versions/1.0.0/manifest.json +15 -0
- package/dist/catalog/chat-models/mi/minicpm-v4.7-35b-a3b-q4/manifest.json +185 -0
- package/dist/catalog/chat-models/mi/minicpm-v4.7-35b-a3b-q4/versions/1.0.0/manifest.json +94 -0
- package/dist/catalog/chat-models/mi/minicpm5-1b-q4/manifest.json +1 -0
- package/dist/catalog/chat-models/mi/minicpm5-2b-q4/manifest.json +1 -0
- package/dist/catalog/chat-models/qw/qwen3.5-0.8b-q4/manifest.json +1 -0
- package/dist/catalog/chat-models/raw-index.json +1 -1
- package/dist/catalog/provenance.json +1 -1
- package/dist/engines/electron-native-reuse.d.ts +9 -0
- package/dist/engines/electron-native-reuse.js +146 -45
- package/dist/engines/native-file-manifest.json +404 -404
- package/dist/engines/native-manifest.d.ts +11 -3
- package/dist/engines/native-manifest.js +48 -18
- package/dist/http/openai-compat/apple-model.d.ts +2 -0
- package/dist/http/openai-compat/apple-model.js +21 -1
- package/dist/http/openai-compat/chat-target.d.ts +12 -0
- package/dist/http/openai-compat/chat-target.js +27 -1
- package/dist/http/openai-compat/engine-failure.d.ts +25 -0
- package/dist/http/openai-compat/engine-failure.js +76 -0
- package/dist/http/openai-compat/responses-streaming.js +2 -1
- package/dist/http/openai-compat/responses-translate.d.ts +1 -1
- package/dist/http/openai-compat/translate.d.ts +1 -1
- package/dist/http/routes/engines.js +44 -3
- package/dist/http/routes/v1-chat.js +30 -8
- package/dist/http/routes/v1-models.d.ts +6 -0
- package/dist/http/routes/v1-models.js +137 -30
- package/dist/http/routes/v1-responses.js +16 -3
- package/dist/in-flight-requests.d.ts +19 -0
- package/dist/in-flight-requests.js +34 -0
- package/dist/inference-service.js +3 -3
- package/dist/knowledge/catalog-host.d.ts +9 -1
- package/dist/knowledge/catalog-host.js +2 -0
- package/dist/knowledge/manager.d.ts +14 -0
- package/dist/knowledge/manager.js +53 -6
- package/dist/knowledge/search-worker.d.ts +1 -1
- package/dist/knowledge/spatial-query.d.ts +2 -3
- package/dist/knowledge/spatial-query.js +12 -6
- package/dist/knowledge/worker-host.js +2 -0
- package/dist/memory/embed-core.d.ts +16 -0
- package/dist/memory/embed-core.js +31 -0
- package/dist/memory/embed-worker.d.ts +7 -3
- package/dist/memory/embed-worker.js +13 -4
- package/dist/memory/embeddings.d.ts +17 -2
- package/dist/memory/embeddings.js +36 -7
- package/dist/models/ensure.d.ts +2 -0
- package/dist/models/ensure.js +9 -0
- package/dist/perf/responsiveness.d.ts +14 -1
- package/dist/perf/responsiveness.js +77 -7
- package/dist/providers/llama-cpp/backend-health.d.ts +30 -0
- package/dist/providers/llama-cpp/backend-health.js +64 -0
- package/dist/providers/llama-cpp/backend-quarantine.d.ts +40 -0
- package/dist/providers/llama-cpp/backend-quarantine.js +100 -0
- package/dist/providers/llama-cpp/build-provider.js +140 -118
- package/dist/providers/llama-cpp/cache-adapter.d.ts +12 -0
- package/dist/providers/llama-cpp/cache-adapter.js +36 -1
- package/dist/providers/llama-cpp/devices.d.ts +11 -2
- package/dist/providers/llama-cpp/devices.js +10 -4
- package/dist/providers/llama-cpp/engine-flags.d.ts +0 -18
- package/dist/providers/llama-cpp/engine-flags.js +12 -18
- package/dist/providers/llama-cpp/gguf-metadata-async.js +7 -0
- package/dist/providers/llama-cpp/gguf-metadata.js +20 -3
- package/dist/providers/llama-cpp/gpu-startup-watch.d.ts +41 -0
- package/dist/providers/llama-cpp/gpu-startup-watch.js +58 -0
- package/dist/providers/llama-cpp/models.d.ts +6 -0
- package/dist/providers/llama-cpp/models.js +19 -1
- package/dist/providers/llama-cpp/offload-planner.d.ts +3 -18
- package/dist/providers/llama-cpp/offload-planner.js +3 -14
- package/dist/providers/llama-cpp/provider.d.ts +8 -0
- package/dist/providers/llama-cpp/provider.js +4 -1
- package/dist/providers/mlx/provider.js +14 -11
- package/dist/providers/mlx/python/batch_processors.py +26 -0
- package/dist/providers/mlx/python/gezel_mlx_server.py +88 -20
- package/dist/providers/mlx/python/qwen4_ple.py +9 -2
- package/dist/providers/mlx/python/response_format.py +374 -0
- package/dist/providers/mlx/python/spec_decode.py +5 -0
- package/dist/providers/mlx/python/think_budget.py +35 -1
- package/dist/providers/mlx/python/tool_grammar.py +62 -3
- package/dist/providers/mlx/reasoning-stream.d.ts +20 -2
- package/dist/providers/mlx/reasoning-stream.js +77 -16
- package/dist/providers/mlx/runtime-diagnostics.d.ts +16 -0
- package/dist/providers/mlx/runtime-diagnostics.js +29 -2
- package/dist/providers/native/capacity-broker.d.ts +51 -10
- package/dist/providers/native/capacity-broker.js +80 -9
- package/dist/providers/native/capacity-budget.d.ts +35 -3
- package/dist/providers/native/capacity-budget.js +49 -5
- package/dist/providers/native/device-capacity.d.ts +1 -1
- package/dist/providers/native/device-capacity.js +23 -5
- package/dist/providers/native/local-engine-runtime.js +15 -13
- package/dist/providers/native/measured-budget.js +2 -0
- package/dist/providers/native/supervisor.d.ts +25 -0
- package/dist/providers/native/supervisor.js +59 -7
- package/dist/remotes/identity.d.ts +1 -0
- package/dist/remotes/identity.js +6 -0
- package/dist/remotes/pinned-fetch.d.ts +2 -0
- package/dist/remotes/pinned-fetch.js +30 -1
- package/dist/remotes/types.d.ts +14 -0
- package/dist/runtime-lock.js +1 -1
- package/dist/system/amdgpu-adapters.d.ts +18 -0
- package/dist/system/amdgpu-adapters.js +55 -0
- package/dist/system/gpu-carve-out.d.ts +84 -0
- package/dist/system/gpu-carve-out.js +111 -0
- package/dist/system/memory.d.ts +17 -1
- package/dist/system/memory.js +105 -14
- package/dist/transformers-cache.d.ts +2 -0
- package/dist/transformers-cache.js +6 -3
- package/dist/utils/service-worker-entry.d.ts +4 -0
- package/dist/utils/service-worker-entry.js +4 -0
- package/package.json +14 -10
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { externalGezelModelId, isRecommendedModel, resolveDistributionProfile, } from '@bendyline/gezel';
|
|
1
|
+
import { estimateManifestKvBytes, externalGezelModelId, isRecommendedModel, llamaCppBackendExclusion, localContextFloorTokens, rateModelsForDevice, resolveDistributionProfile, } from '@bendyline/gezel';
|
|
2
2
|
import { resolveOnDeviceProvider } from '@bendyline/gezel/native';
|
|
3
3
|
import { Hono } from 'hono';
|
|
4
|
+
import { currentLlamaCppBackend } from '../../providers/llama-cpp/backend-health.js';
|
|
5
|
+
import { CapacityBroker } from '../../providers/native/capacity-broker.js';
|
|
6
|
+
import { detectMemoryProfileCached } from '../../system/memory.js';
|
|
4
7
|
import { appleSystemModel } from '../openai-compat/apple-model.js';
|
|
5
8
|
import { resolveFallbackGezelId } from '../openai-compat/chat-target.js';
|
|
6
9
|
/**
|
|
@@ -30,34 +33,110 @@ const PROVIDERS_TO_ENUMERATE = [
|
|
|
30
33
|
'ds4',
|
|
31
34
|
];
|
|
32
35
|
const LOCAL_PROVIDERS = ['llama-cpp', 'mlx', 'ds4'];
|
|
36
|
+
/**
|
|
37
|
+
* Engines that enforce `response_format` with a grammar: llama-server's
|
|
38
|
+
* `json_schema`, and the MLX sidecar's llguidance JSON grammar
|
|
39
|
+
* (`providers/mlx/python/response_format.py`). ds4 shares llama.cpp's request
|
|
40
|
+
* map, but nothing here shows its server enforces a schema, so it is not
|
|
41
|
+
* advertised.
|
|
42
|
+
*/
|
|
43
|
+
function enforcesResponseFormat(provider) {
|
|
44
|
+
return provider === 'llama-cpp' || provider === 'mlx';
|
|
45
|
+
}
|
|
46
|
+
function catalogCapabilities(manifest, provider) {
|
|
47
|
+
return {
|
|
48
|
+
text: true,
|
|
49
|
+
tools: manifest.supportsTools,
|
|
50
|
+
structuredOutput: enforcesResponseFormat(provider),
|
|
51
|
+
images: false,
|
|
52
|
+
foregroundOnly: false,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
33
55
|
function catalogBackend(manifest) {
|
|
56
|
+
// ds4 has no portable fallback: `discoverNativeBinaries` stamps its binary
|
|
57
|
+
// only where a build for this host is on disk, which is the gate the
|
|
58
|
+
// Settings availability check reads too. Windows has none.
|
|
59
|
+
const ds4 = manifest.ds4 && process.env.GEZEL_DS4_SERVER_BIN ? 'ds4' : null;
|
|
34
60
|
// Store hosts have no provisioned Python runtime. Only advertise models
|
|
35
61
|
// backed by bundled native engines until a frozen MLX runtime ships.
|
|
36
62
|
const store = resolveDistributionProfile().pythonProvisioning === 'frozen-only';
|
|
63
|
+
const llamaCpp = manifest.llamaCpp && !llamaCppBackendExclusion(manifest.llamaCpp, currentLlamaCppBackend());
|
|
37
64
|
if (store)
|
|
38
|
-
return
|
|
65
|
+
return llamaCpp ? 'llama-cpp' : ds4;
|
|
39
66
|
const preferred = resolveOnDeviceProvider(process.platform, process.arch);
|
|
40
67
|
if (preferred === 'mlx' && manifest.mlx && !manifest.mlx.disabledReason)
|
|
41
68
|
return 'mlx';
|
|
42
|
-
if (preferred === 'llama-cpp' &&
|
|
69
|
+
if (preferred === 'llama-cpp' && llamaCpp)
|
|
43
70
|
return 'llama-cpp';
|
|
44
|
-
if (
|
|
71
|
+
if (llamaCpp)
|
|
45
72
|
return 'llama-cpp';
|
|
46
73
|
if (manifest.mlx && !manifest.mlx.disabledReason && process.platform === 'darwin')
|
|
47
74
|
return 'mlx';
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
75
|
+
return ds4;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* What loading a catalog model would hold, priced as the capacity broker
|
|
79
|
+
* prices it: the catalog's measured pin when it has one, else the engine's
|
|
80
|
+
* estimate, plus KV at the context floor this machine launches with. ds4's
|
|
81
|
+
* figure already bounds its streamed working set, so it takes no extra KV.
|
|
82
|
+
*/
|
|
83
|
+
function footprintBytes({ manifest, provider, source }, memory) {
|
|
84
|
+
const mmprojBytes = provider === 'llama-cpp' ? manifest.llamaCpp?.mmproj?.sizeBytes : undefined;
|
|
85
|
+
const weights = source.residentBytes ??
|
|
86
|
+
CapacityBroker.estimateResidentBytes(provider, source.approxSizeBytes, mmprojBytes ? { mmprojBytes } : undefined);
|
|
87
|
+
if (provider === 'ds4')
|
|
88
|
+
return weights;
|
|
89
|
+
const ctxTokens = localContextFloorTokens({
|
|
90
|
+
totalRamBytes: memory.totalRamBytes,
|
|
91
|
+
gpuVramBytes: fastGpuBytes(memory),
|
|
92
|
+
});
|
|
93
|
+
// MLX keeps its cache in f16; llama.cpp's default q8_0 is the helper's default.
|
|
94
|
+
return (weights +
|
|
95
|
+
estimateManifestKvBytes(manifest, {
|
|
96
|
+
ctxTokens,
|
|
97
|
+
...(provider === 'mlx' ? { cacheScale: 1 } : {}),
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* An integrated adapter's advertised allocation is shared system RAM, not a
|
|
102
|
+
* fast pool beside it, so fit treats that host as RAM-paced (as Settings does).
|
|
103
|
+
*/
|
|
104
|
+
function fastGpuBytes(memory) {
|
|
105
|
+
return memory.gpuMemoryKind === 'integrated' ? null : memory.gpuVramBytes;
|
|
106
|
+
}
|
|
107
|
+
function recoDevice(memory) {
|
|
108
|
+
return {
|
|
109
|
+
platform: memory.platform,
|
|
110
|
+
gpuVramBytes: fastGpuBytes(memory),
|
|
111
|
+
gpuMemoryKind: memory.gpuMemoryKind,
|
|
112
|
+
totalRamBytes: memory.totalRamBytes,
|
|
113
|
+
usableBytes: memory.usableBytes,
|
|
114
|
+
budgetBytes: memory.budgetBytes,
|
|
115
|
+
};
|
|
51
116
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
117
|
+
/** Curated order, for when this machine's memory could not be read. */
|
|
118
|
+
function byCuratedScore(a, b) {
|
|
119
|
+
const aRecommended = isRecommendedModel(a.manifest);
|
|
120
|
+
const bRecommended = isRecommendedModel(b.manifest);
|
|
121
|
+
if (aRecommended !== bRecommended)
|
|
122
|
+
return aRecommended ? -1 : 1;
|
|
123
|
+
const score = (b.manifest.recoScore ?? 0) - (a.manifest.recoScore ?? 0);
|
|
124
|
+
return score || (a.entry.name ?? a.entry.id).localeCompare(b.entry.name ?? b.entry.id);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Downloadable models, each rated against this machine (`fit`,
|
|
128
|
+
* `recommendation`) and listed best first. A model too big to run here stays
|
|
129
|
+
* listed with `fit.tier: 'too-big'` and a 0 score, so a client decides
|
|
130
|
+
* whether to hide it.
|
|
131
|
+
*/
|
|
132
|
+
async function downloadableCatalogEntries(items, created, existingIds, memoryProfile) {
|
|
133
|
+
const candidates = items.flatMap((item) => {
|
|
56
134
|
const manifest = item.manifest;
|
|
57
135
|
if (manifest.kind !== 'chat-model')
|
|
58
136
|
return [];
|
|
59
137
|
const provider = catalogBackend(manifest);
|
|
60
|
-
|
|
138
|
+
const source = provider === 'mlx' ? manifest.mlx : provider === 'ds4' ? manifest.ds4 : manifest.llamaCpp;
|
|
139
|
+
if (!provider || !source)
|
|
61
140
|
return [];
|
|
62
141
|
const id = `${provider}:${manifest.id}`;
|
|
63
142
|
if (existingIds.has(id))
|
|
@@ -65,6 +144,8 @@ async function downloadableCatalogEntries(ctx, created, existingIds) {
|
|
|
65
144
|
return [
|
|
66
145
|
{
|
|
67
146
|
manifest,
|
|
147
|
+
provider,
|
|
148
|
+
source,
|
|
68
149
|
entry: {
|
|
69
150
|
id,
|
|
70
151
|
object: 'model',
|
|
@@ -78,26 +159,41 @@ async function downloadableCatalogEntries(ctx, created, existingIds) {
|
|
|
78
159
|
locality: 'on-device',
|
|
79
160
|
download_bytes: manifest.approxSizeBytes,
|
|
80
161
|
...(manifest.contextWindow ? { context_window: manifest.contextWindow } : {}),
|
|
81
|
-
capabilities:
|
|
82
|
-
text: true,
|
|
83
|
-
tools: manifest.supportsTools,
|
|
84
|
-
structuredOutput: false,
|
|
85
|
-
images: false,
|
|
86
|
-
foregroundOnly: false,
|
|
87
|
-
},
|
|
162
|
+
capabilities: catalogCapabilities(manifest, provider),
|
|
88
163
|
},
|
|
89
164
|
},
|
|
90
165
|
];
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
166
|
+
});
|
|
167
|
+
if (candidates.length === 0)
|
|
168
|
+
return [];
|
|
169
|
+
const memory = await memoryProfile().catch(() => null);
|
|
170
|
+
if (!memory)
|
|
171
|
+
return candidates.sort(byCuratedScore).map(({ entry }) => entry);
|
|
172
|
+
const rated = candidates.map((candidate) => {
|
|
173
|
+
const { entry, manifest, source } = candidate;
|
|
174
|
+
return {
|
|
175
|
+
id: entry.id,
|
|
176
|
+
residentBytes: footprintBytes(candidate, memory),
|
|
177
|
+
tags: manifest.tags,
|
|
178
|
+
supportsTools: manifest.supportsTools,
|
|
179
|
+
...(manifest.recoScore !== undefined ? { recoScore: manifest.recoScore } : {}),
|
|
180
|
+
...(manifest.licenseClass ? { licenseClass: manifest.licenseClass } : {}),
|
|
181
|
+
...(source.quantization ? { quantization: source.quantization } : {}),
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
const entries = new Map(candidates.map(({ entry }) => [entry.id, entry]));
|
|
185
|
+
return rateModelsForDevice(rated, recoDevice(memory)).flatMap((rating) => {
|
|
186
|
+
const entry = entries.get(rating.id);
|
|
187
|
+
if (!entry)
|
|
188
|
+
return [];
|
|
189
|
+
return [
|
|
190
|
+
{
|
|
191
|
+
...entry,
|
|
192
|
+
fit: { tier: rating.fit.tier, label: rating.fit.label, detail: rating.fit.detail },
|
|
193
|
+
recommendation: { score: rating.score, recommended: rating.recommended },
|
|
194
|
+
},
|
|
195
|
+
];
|
|
196
|
+
});
|
|
101
197
|
}
|
|
102
198
|
async function buildModelEntries(ctx, created, options) {
|
|
103
199
|
// Advertise every gezel as a selectable OpenAI "model". Keep the
|
|
@@ -128,6 +224,14 @@ async function buildModelEntries(ctx, created, options) {
|
|
|
128
224
|
...(gezel.id === fallbackGezelId ? { is_fallback: true } : {}),
|
|
129
225
|
}));
|
|
130
226
|
}
|
|
227
|
+
const catalogItems = await ctx.catalog.list('chat-model').catch(() => []);
|
|
228
|
+
// An installed model carries the capabilities its catalog entry
|
|
229
|
+
// advertised before download; a model with no entry stays unflagged
|
|
230
|
+
// rather than guessing its tools or images support.
|
|
231
|
+
const manifests = new Map();
|
|
232
|
+
for (const item of catalogItems)
|
|
233
|
+
if (item.manifest.kind === 'chat-model')
|
|
234
|
+
manifests.set(item.manifest.id, item.manifest);
|
|
131
235
|
const providers = (options.localOnly ? LOCAL_PROVIDERS : PROVIDERS_TO_ENUMERATE).filter((provider) => provider !== 'mlx' || resolveDistributionProfile().pythonProvisioning !== 'frozen-only');
|
|
132
236
|
const buckets = await Promise.all(providers.map(async (provider) => {
|
|
133
237
|
try {
|
|
@@ -146,6 +250,9 @@ async function buildModelEntries(ctx, created, options) {
|
|
|
146
250
|
: {}),
|
|
147
251
|
...(m.contextWindow ? { context_window: m.contextWindow } : {}),
|
|
148
252
|
...(m.supportsReasoning ? { supports_reasoning: true } : {}),
|
|
253
|
+
...(enforcesResponseFormat(provider) && manifests.has(m.id)
|
|
254
|
+
? { capabilities: catalogCapabilities(manifests.get(m.id), provider) }
|
|
255
|
+
: {}),
|
|
149
256
|
}));
|
|
150
257
|
}
|
|
151
258
|
catch {
|
|
@@ -156,7 +263,7 @@ async function buildModelEntries(ctx, created, options) {
|
|
|
156
263
|
}));
|
|
157
264
|
const availableEntries = buckets.flat();
|
|
158
265
|
const existingIds = new Set([...gezelEntries, ...availableEntries].map((entry) => entry.id));
|
|
159
|
-
const downloadable = await downloadableCatalogEntries(
|
|
266
|
+
const downloadable = await downloadableCatalogEntries(catalogItems, created, existingIds, options.memoryProfile ?? (() => detectMemoryProfileCached()));
|
|
160
267
|
const apple = await appleSystemModel(created);
|
|
161
268
|
return [...gezelEntries, ...apple, ...availableEntries, ...downloadable];
|
|
162
269
|
}
|
|
@@ -3,10 +3,12 @@ import { Hono } from 'hono';
|
|
|
3
3
|
import { bodyLimit } from 'hono/body-limit';
|
|
4
4
|
import { streamSSE } from 'hono/streaming';
|
|
5
5
|
import { ZodError } from 'zod';
|
|
6
|
+
import { UnsupportedResponseFormatError } from '../../providers/mlx/runtime-diagnostics.js';
|
|
6
7
|
import { ExternalToolsUnsupportedError, ModelNotInstalledError, } from '../../providers/types.js';
|
|
7
8
|
import { appendCallerOwnedActionLedger } from '../openai-compat/action-ledger.js';
|
|
8
9
|
import { profileForCallerOwnedInference } from '../openai-compat/caller-owned-profile.js';
|
|
9
|
-
import { resolveChatTarget } from '../openai-compat/chat-target.js';
|
|
10
|
+
import { resolveChatTarget, resolvePinnedModelTarget, } from '../openai-compat/chat-target.js';
|
|
11
|
+
import { engineFailure } from '../openai-compat/engine-failure.js';
|
|
10
12
|
import { runResponsesNonStreaming, runResponsesStreaming, } from '../openai-compat/responses-streaming.js';
|
|
11
13
|
import { ResponsesRequestSchema, translateResponsesRequest, } from '../openai-compat/responses-translate.js';
|
|
12
14
|
import { flattenTranscriptIntoPrompt, parseGezelModelRef, resolveModelTarget, } from '../openai-compat/translate.js';
|
|
@@ -220,7 +222,7 @@ export function v1ResponsesRoutes(ctx, opts = {}) {
|
|
|
220
222
|
event: 'error',
|
|
221
223
|
data: JSON.stringify({
|
|
222
224
|
type: 'error',
|
|
223
|
-
code:
|
|
225
|
+
code: engineFailure(err).code,
|
|
224
226
|
message: 'The response stream ended unexpectedly.',
|
|
225
227
|
param: null,
|
|
226
228
|
}),
|
|
@@ -259,7 +261,15 @@ export function v1ResponsesRoutes(ctx, opts = {}) {
|
|
|
259
261
|
if (err instanceof ExternalToolsUnsupportedError) {
|
|
260
262
|
return invalidRequest(c, err.message, 'tools_not_supported_for_provider');
|
|
261
263
|
}
|
|
264
|
+
if (err instanceof UnsupportedResponseFormatError) {
|
|
265
|
+
return invalidRequest(c, err.message, err.code);
|
|
266
|
+
}
|
|
262
267
|
const message = err instanceof Error ? err.message : String(err);
|
|
268
|
+
const failure = engineFailure(err);
|
|
269
|
+
if (failure.code !== 'provider_error') {
|
|
270
|
+
log.warn(`Responses request failed (${failure.code}): ${message}`);
|
|
271
|
+
return c.json({ error: { message, type: failure.type, code: failure.code } }, failure.status);
|
|
272
|
+
}
|
|
263
273
|
if (message.includes('messages must') ||
|
|
264
274
|
message.includes('empty') ||
|
|
265
275
|
message.includes('cannot be an assistant turn')) {
|
|
@@ -284,7 +294,10 @@ async function resolveResponsesTarget(model, ctx) {
|
|
|
284
294
|
systemPrefix: '',
|
|
285
295
|
};
|
|
286
296
|
}
|
|
287
|
-
|
|
297
|
+
const pinned = await resolvePinnedModelTarget(ctx, model);
|
|
298
|
+
if (pinned)
|
|
299
|
+
return pinned;
|
|
300
|
+
throw new Error(`Unknown model "${model}". Use an explicit <provider>:<model> id, a model pinned as a default, or an advertised gezel:<role>-<name> alias.`);
|
|
288
301
|
}
|
|
289
302
|
function prependSystemPrefix(input, prefix) {
|
|
290
303
|
if (!prefix)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-process transport's open requests, and the one handle that keeps Node
|
|
3
|
+
* running while any of them is open.
|
|
4
|
+
*
|
|
5
|
+
* The direct transport has no socket, so nothing a request awaits is
|
|
6
|
+
* guaranteed to hold the event loop: engine supervisors and workers unref their
|
|
7
|
+
* timers and threads so that an idle host never blocks exit. A plain Node
|
|
8
|
+
* script exited with code 13 ("unsettled top-level await") inside `chat()`
|
|
9
|
+
* while llama-server cold-started; Electron's own handles had hidden it.
|
|
10
|
+
*/
|
|
11
|
+
export declare class InFlightRequests {
|
|
12
|
+
private readonly open;
|
|
13
|
+
private keepAlive;
|
|
14
|
+
get size(): number;
|
|
15
|
+
add(controller: AbortController): void;
|
|
16
|
+
delete(controller: AbortController): void;
|
|
17
|
+
/** Abort and forget every open request, including a body nobody will read. */
|
|
18
|
+
abortAll(): void;
|
|
19
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-process transport's open requests, and the one handle that keeps Node
|
|
3
|
+
* running while any of them is open.
|
|
4
|
+
*
|
|
5
|
+
* The direct transport has no socket, so nothing a request awaits is
|
|
6
|
+
* guaranteed to hold the event loop: engine supervisors and workers unref their
|
|
7
|
+
* timers and threads so that an idle host never blocks exit. A plain Node
|
|
8
|
+
* script exited with code 13 ("unsettled top-level await") inside `chat()`
|
|
9
|
+
* while llama-server cold-started; Electron's own handles had hidden it.
|
|
10
|
+
*/
|
|
11
|
+
export class InFlightRequests {
|
|
12
|
+
open = new Set();
|
|
13
|
+
keepAlive;
|
|
14
|
+
get size() {
|
|
15
|
+
return this.open.size;
|
|
16
|
+
}
|
|
17
|
+
add(controller) {
|
|
18
|
+
this.open.add(controller);
|
|
19
|
+
this.keepAlive ??= setInterval(() => { }, 2 ** 30);
|
|
20
|
+
}
|
|
21
|
+
delete(controller) {
|
|
22
|
+
if (!this.open.delete(controller) || this.open.size > 0)
|
|
23
|
+
return;
|
|
24
|
+
clearInterval(this.keepAlive);
|
|
25
|
+
this.keepAlive = undefined;
|
|
26
|
+
}
|
|
27
|
+
/** Abort and forget every open request, including a body nobody will read. */
|
|
28
|
+
abortAll() {
|
|
29
|
+
for (const controller of [...this.open]) {
|
|
30
|
+
controller.abort();
|
|
31
|
+
this.delete(controller);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -18,6 +18,7 @@ import { v1KnowledgeRoutes } from './http/routes/v1-knowledge.js';
|
|
|
18
18
|
import { v1ModelsEnsureRoutes } from './http/routes/v1-models-ensure.js';
|
|
19
19
|
import { v1ModelsRoutes } from './http/routes/v1-models.js';
|
|
20
20
|
import { v1ResponsesRoutes } from './http/routes/v1-responses.js';
|
|
21
|
+
import { InFlightRequests } from './in-flight-requests.js';
|
|
21
22
|
import { KnowledgeManager } from './knowledge/manager.js';
|
|
22
23
|
import { createWorkerCatalogHost } from './knowledge/worker-host.js';
|
|
23
24
|
import { embeddingsHealth, openEmbeddings, shutdownEmbeddings } from './memory/embeddings.js';
|
|
@@ -44,14 +45,13 @@ export async function startService(options = {}) {
|
|
|
44
45
|
const runtimeDir = join(home, 'runtime');
|
|
45
46
|
const lock = await acquireSingleInstanceLock({ runtimeDir, lockPath: join(runtimeDir, 'lock') });
|
|
46
47
|
const cleanup = [() => lock.release()];
|
|
47
|
-
const requests = new
|
|
48
|
+
const requests = new InFlightRequests();
|
|
48
49
|
let stopping;
|
|
49
50
|
let closed = false;
|
|
50
51
|
const stop = () => {
|
|
51
52
|
stopping ??= (async () => {
|
|
52
53
|
closed = true;
|
|
53
|
-
|
|
54
|
-
controller.abort();
|
|
54
|
+
requests.abortAll();
|
|
55
55
|
const errors = [];
|
|
56
56
|
for (const close of cleanup.reverse())
|
|
57
57
|
try {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* (docs/gezk-format.md). The in-process implementation is test/CLI-only;
|
|
8
8
|
* production fails closed if its worker becomes unavailable.
|
|
9
9
|
*/
|
|
10
|
-
import type { CatalogAssetFile, CatalogAssetInfo, CatalogAssetRead, CatalogChunkHit, CatalogDocumentMeta, CatalogTopic, CatalogValidationReport } from '@bendyline/gezel-knowledge';
|
|
10
|
+
import type { CatalogAssetFile, CatalogAssetInfo, CatalogAssetRead, CatalogChunkHit, CatalogDocumentInitial, CatalogDocumentMeta, CatalogTopic, CatalogValidationReport } from '@bendyline/gezel-knowledge';
|
|
11
11
|
import type { KnowledgeRadius } from '@bendyline/gezel';
|
|
12
12
|
import type { SpatialMatch } from '@bendyline/gezel-knowledge';
|
|
13
13
|
export interface MountSpec {
|
|
@@ -78,6 +78,14 @@ export interface KnowledgeCatalogHost {
|
|
|
78
78
|
documents: CatalogDocumentMeta[];
|
|
79
79
|
total: number;
|
|
80
80
|
}>;
|
|
81
|
+
documentInitials(key: string, opts: {
|
|
82
|
+
topicId?: string;
|
|
83
|
+
descendants?: boolean;
|
|
84
|
+
}): Promise<CatalogDocumentInitial[]>;
|
|
85
|
+
documentPosition(key: string, documentId: string, opts: {
|
|
86
|
+
topicId?: string;
|
|
87
|
+
descendants?: boolean;
|
|
88
|
+
}): Promise<number | null>;
|
|
81
89
|
nearbyDocuments(key: string, radius: KnowledgeRadius, opts: {
|
|
82
90
|
offset?: number;
|
|
83
91
|
limit?: number;
|
|
@@ -158,6 +158,8 @@ export async function createInProcessCatalogHost() {
|
|
|
158
158
|
validate: async (rootDir, deep) => validateExtractedCatalog(rootDir, { deep }),
|
|
159
159
|
topics: async (key) => mustGet(handles, key).topics(),
|
|
160
160
|
documentsPage: async (key, opts) => mustGet(handles, key).documentsPage(opts),
|
|
161
|
+
documentInitials: async (key, opts) => mustGet(handles, key).documentInitials(opts),
|
|
162
|
+
documentPosition: async (key, documentId, opts) => mustGet(handles, key).documentPosition(documentId, opts),
|
|
161
163
|
nearbyDocuments: async (key, radius, opts) => mustGet(handles, key).nearbyDocuments(radius, opts),
|
|
162
164
|
getDocument: async (key, documentId) => mustGet(handles, key).getDocument(documentId),
|
|
163
165
|
getChunk: async (key, documentId, chunkUid) => mustGet(handles, key).getChunk(documentId, chunkUid),
|
|
@@ -121,6 +121,12 @@ export declare class KnowledgeManager {
|
|
|
121
121
|
* what the Knowledge screen's browser renders. Offline-safe: gilde is local.
|
|
122
122
|
*/
|
|
123
123
|
available(): Promise<KnowledgeAvailableCatalog[]>;
|
|
124
|
+
/**
|
|
125
|
+
* The search model an install of a catalog with this profile would also
|
|
126
|
+
* download. None when the catalog searches with the daemon's own embedder
|
|
127
|
+
* (an install never fetches that) or the profile's pinned files are cached.
|
|
128
|
+
*/
|
|
129
|
+
private searchModelDownloadBytes;
|
|
124
130
|
/** Resolve a catalogId (URI authority) to its mounted info, or null. */
|
|
125
131
|
mountedCatalog(catalogId: string): MountedInfo | null;
|
|
126
132
|
/**
|
|
@@ -206,6 +212,14 @@ export declare class KnowledgeManager {
|
|
|
206
212
|
limit?: number;
|
|
207
213
|
descendants?: boolean;
|
|
208
214
|
}): ReturnType<KnowledgeCatalogHost['documentsPage']>;
|
|
215
|
+
documentInitials(catalogId: string, opts: {
|
|
216
|
+
topicId?: string;
|
|
217
|
+
descendants?: boolean;
|
|
218
|
+
}): ReturnType<KnowledgeCatalogHost['documentInitials']>;
|
|
219
|
+
documentPosition(catalogId: string, documentId: string, opts: {
|
|
220
|
+
topicId?: string;
|
|
221
|
+
descendants?: boolean;
|
|
222
|
+
}): ReturnType<KnowledgeCatalogHost['documentPosition']>;
|
|
209
223
|
getDocument(catalogId: string, documentId: string): ReturnType<KnowledgeCatalogHost['getDocument']>;
|
|
210
224
|
/** One passage by its citation id, with the document's title and origin. */
|
|
211
225
|
getPassage(catalogId: string, documentId: string, chunkUid: string): Promise<KnowledgePassageRead | null>;
|
|
@@ -13,7 +13,7 @@ import { readFile, readdir, rm, stat } from 'node:fs/promises';
|
|
|
13
13
|
import { dirname, join, resolve } from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
import { KnowledgeMachineInventorySchema, KnowledgeNearbyRequestSchema, createLogger, formatKnowledgeUri, parseKnowledgeUri, resolveSecurityPolicy, sameVectorSpace, } from '@bendyline/gezel';
|
|
16
|
-
import { compareCatalogVersions, knowledgeEmbeddingProfile, readGezkManifest, } from '@bendyline/gezel-knowledge';
|
|
16
|
+
import { compareCatalogVersions, knowledgeEmbeddingProfile, knowledgeProfileQueryModelBytes, missingPinnedFiles, readGezkManifest, } from '@bendyline/gezel-knowledge';
|
|
17
17
|
import { knowledgeCatalogVersionDir, knowledgeCatalogsDir, knowledgeDownloadsDir, } from '@bendyline/gezel/paths';
|
|
18
18
|
import { HANDBOEK_KNOWLEDGE_CATALOG, HANDBOEK_KNOWLEDGE_PUBLISHER } from './bundled-catalog.js';
|
|
19
19
|
import { scoreResult } from '@bendyline/gezel';
|
|
@@ -21,10 +21,11 @@ import { sharedKnowledgeRoot, sharedKnowledgeVersionDir, } from '../machine-engi
|
|
|
21
21
|
import { embedKnowledgeQuery, sharesDaemonEmbedder } from '../memory/embeddings.js';
|
|
22
22
|
import { ChatModelInstallRegistry } from '../models/install-registry.js';
|
|
23
23
|
import { beginPerfWork } from '../perf/responsiveness.js';
|
|
24
|
+
import { HF_CACHE_DIR_ENV, transformersCacheDir } from '../transformers-cache.js';
|
|
24
25
|
import { resolveKnowledgeCatalogSource } from './catalog-source.js';
|
|
25
26
|
import { KNOWLEDGE_DOWNLOAD_KEY_PATTERN, installKnowledgeCatalog, knowledgeDownloadKey, pruneOtherKnowledgeCatalogVersions, } from './install.js';
|
|
26
27
|
import { KnowledgeRegistry } from './registry.js';
|
|
27
|
-
import { KnowledgeSpatialCursorError, encodeNearbyCursor, nearbyCursorOffset,
|
|
28
|
+
import { KnowledgeSpatialCursorError, encodeNearbyCursor, nearbyCursorOffset, spatialDocumentIdentity, } from './spatial-query.js';
|
|
28
29
|
import { resolveKnowledgeVectorFloors } from './vector-floors.js';
|
|
29
30
|
const log = createLogger('knowledge');
|
|
30
31
|
/** Explicit-search shard budget (S) across every active catalog (§3.4). */
|
|
@@ -59,6 +60,9 @@ function semanticSearchModeFor(profile) {
|
|
|
59
60
|
return 'profile';
|
|
60
61
|
return 'keyword-only';
|
|
61
62
|
}
|
|
63
|
+
function searchModelOf(profile) {
|
|
64
|
+
return { profileId: profile.id, repo: profile.model.repo };
|
|
65
|
+
}
|
|
62
66
|
/**
|
|
63
67
|
* Explicit search fuses three arms per catalog by reciprocal rank (k = 60):
|
|
64
68
|
* the vector chunks (best chunk per document, by rerank cosine), the
|
|
@@ -409,10 +413,18 @@ export class KnowledgeManager {
|
|
|
409
413
|
const shared = await this.sharedInventoryKeys();
|
|
410
414
|
const activeIds = new Set(this.installs.active().map((a) => a.id));
|
|
411
415
|
const partials = await this.partialDownloadKeys();
|
|
416
|
+
const modelBytes = new Map();
|
|
417
|
+
for (const item of gilde.values()) {
|
|
418
|
+
const profileId = item.embeddingProfile.id;
|
|
419
|
+
if (!modelBytes.has(profileId)) {
|
|
420
|
+
modelBytes.set(profileId, await this.searchModelDownloadBytes(profileId));
|
|
421
|
+
}
|
|
422
|
+
}
|
|
412
423
|
return [...gilde.values()]
|
|
413
424
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
414
425
|
.map((item) => {
|
|
415
426
|
const entry = registry.find((c) => c.ref.publisherId === item.publisherId && c.ref.catalogId === item.id);
|
|
427
|
+
const searchModelDownloadBytes = modelBytes.get(item.embeddingProfile.id);
|
|
416
428
|
return {
|
|
417
429
|
id: item.id,
|
|
418
430
|
publisherId: item.publisherId,
|
|
@@ -451,9 +463,24 @@ export class KnowledgeManager {
|
|
|
451
463
|
sharedOnDevice: shared.has(`${item.publisherId}/${item.id}/${item.version}/${item.sha256}`),
|
|
452
464
|
installing: activeIds.has(item.id),
|
|
453
465
|
incompleteDownload: partials.has(item.sha256.slice(0, 16)),
|
|
466
|
+
...(searchModelDownloadBytes ? { searchModelDownloadBytes } : {}),
|
|
454
467
|
};
|
|
455
468
|
});
|
|
456
469
|
}
|
|
470
|
+
/**
|
|
471
|
+
* The search model an install of a catalog with this profile would also
|
|
472
|
+
* download. None when the catalog searches with the daemon's own embedder
|
|
473
|
+
* (an install never fetches that) or the profile's pinned files are cached.
|
|
474
|
+
*/
|
|
475
|
+
async searchModelDownloadBytes(profileId) {
|
|
476
|
+
const profile = knowledgeEmbeddingProfile(profileId);
|
|
477
|
+
const bytes = knowledgeProfileQueryModelBytes(profileId);
|
|
478
|
+
if (!profile || bytes === null || semanticSearchModeFor(profile) !== 'profile')
|
|
479
|
+
return undefined;
|
|
480
|
+
const cacheDir = process.env[HF_CACHE_DIR_ENV] ?? transformersCacheDir(this.opts.home);
|
|
481
|
+
const missing = await missingPinnedFiles(profile, { cacheDir });
|
|
482
|
+
return missing.length > 0 ? bytes : undefined;
|
|
483
|
+
}
|
|
457
484
|
/** Resolve a catalogId (URI authority) to its mounted info, or null. */
|
|
458
485
|
mountedCatalog(catalogId) {
|
|
459
486
|
for (const info of this.mountedByKey.values()) {
|
|
@@ -676,7 +703,13 @@ export class KnowledgeManager {
|
|
|
676
703
|
let warning;
|
|
677
704
|
const mounted = this.mountedByKey.get(key);
|
|
678
705
|
if (mounted?.semanticSearch === 'profile') {
|
|
679
|
-
yield {
|
|
706
|
+
yield {
|
|
707
|
+
type: 'progress',
|
|
708
|
+
phase: 'embedder',
|
|
709
|
+
bytesDone: 0,
|
|
710
|
+
bytesTotal: 0,
|
|
711
|
+
searchModel: searchModelOf(mounted.embedding),
|
|
712
|
+
};
|
|
680
713
|
warning = yield* this.prewarmProfile(mounted.embedding);
|
|
681
714
|
}
|
|
682
715
|
yield { ...event, ...(warning ? { warning } : {}) };
|
|
@@ -746,7 +779,12 @@ export class KnowledgeManager {
|
|
|
746
779
|
(settled || Date.now() - lastReportAt >= EMBEDDER_PROGRESS_INTERVAL_MS)) {
|
|
747
780
|
progress.reported = latest;
|
|
748
781
|
lastReportAt = Date.now();
|
|
749
|
-
yield {
|
|
782
|
+
yield {
|
|
783
|
+
type: 'progress',
|
|
784
|
+
phase: 'embedder',
|
|
785
|
+
...latest,
|
|
786
|
+
searchModel: searchModelOf(profile),
|
|
787
|
+
};
|
|
750
788
|
continue;
|
|
751
789
|
}
|
|
752
790
|
if (settled)
|
|
@@ -802,6 +840,9 @@ export class KnowledgeManager {
|
|
|
802
840
|
phase,
|
|
803
841
|
bytesDone: lastEvent?.type === 'progress' ? lastEvent.bytesDone : 0,
|
|
804
842
|
bytesTotal: lastEvent?.type === 'progress' ? lastEvent.bytesTotal : 0,
|
|
843
|
+
...(lastEvent?.type === 'progress' && lastEvent.searchModel
|
|
844
|
+
? { searchModel: lastEvent.searchModel }
|
|
845
|
+
: {}),
|
|
805
846
|
};
|
|
806
847
|
});
|
|
807
848
|
}
|
|
@@ -1035,6 +1076,12 @@ export class KnowledgeManager {
|
|
|
1035
1076
|
async documentsPage(catalogId, opts) {
|
|
1036
1077
|
return this.opts.host.documentsPage(this.requireMounted(catalogId).key, opts);
|
|
1037
1078
|
}
|
|
1079
|
+
async documentInitials(catalogId, opts) {
|
|
1080
|
+
return this.opts.host.documentInitials(this.requireMounted(catalogId).key, opts);
|
|
1081
|
+
}
|
|
1082
|
+
async documentPosition(catalogId, documentId, opts) {
|
|
1083
|
+
return this.opts.host.documentPosition(this.requireMounted(catalogId).key, documentId, opts);
|
|
1084
|
+
}
|
|
1038
1085
|
async getDocument(catalogId, documentId) {
|
|
1039
1086
|
return this.opts.host.getDocument(this.requireMounted(catalogId).key, documentId);
|
|
1040
1087
|
}
|
|
@@ -1145,7 +1192,7 @@ export class KnowledgeManager {
|
|
|
1145
1192
|
documentId: document.id,
|
|
1146
1193
|
}),
|
|
1147
1194
|
};
|
|
1148
|
-
const identity =
|
|
1195
|
+
const identity = spatialDocumentIdentity(info.ref.publisherId, info.ref.catalogId, document.id);
|
|
1149
1196
|
const previous = unique.get(identity);
|
|
1150
1197
|
if (!previous || result.distanceMeters < previous.distanceMeters)
|
|
1151
1198
|
unique.set(identity, result);
|
|
@@ -1383,7 +1430,7 @@ export class KnowledgeManager {
|
|
|
1383
1430
|
const count = perCatalogCount.get(entry.catalogKey) ?? 0;
|
|
1384
1431
|
if (capPerCatalog && count >= PER_CATALOG_CAP)
|
|
1385
1432
|
continue;
|
|
1386
|
-
const spatialIdentity =
|
|
1433
|
+
const spatialIdentity = spatialDocumentIdentity(info.ref.publisherId, info.ref.catalogId, entry.documentId);
|
|
1387
1434
|
if (opts.spatial && seenSpatial.has(spatialIdentity))
|
|
1388
1435
|
continue;
|
|
1389
1436
|
const relevance = fusedRankRelevance(out.length);
|
|
@@ -12,6 +12,6 @@
|
|
|
12
12
|
*/
|
|
13
13
|
export interface KnowledgeWorkerRequest {
|
|
14
14
|
id: number;
|
|
15
|
-
op: 'mount' | 'unmount' | 'mounted' | 'prewarm' | 'validate' | 'topics' | 'documentsPage' | 'nearbyDocuments' | 'getDocument' | 'getChunk' | 'assets' | 'readAsset' | 'assetFile' | 'search' | 'dispose';
|
|
15
|
+
op: 'mount' | 'unmount' | 'mounted' | 'prewarm' | 'validate' | 'topics' | 'documentsPage' | 'documentInitials' | 'documentPosition' | 'nearbyDocuments' | 'getDocument' | 'getChunk' | 'assets' | 'readAsset' | 'assetFile' | 'search' | 'dispose';
|
|
16
16
|
args: unknown[];
|
|
17
17
|
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Radius pagination binds offsets to the authorized catalog snapshot and
|
|
3
3
|
* query. Cursors confer no authority; the manager recalculates scope on
|
|
4
|
-
* every request.
|
|
5
|
-
* declared regional catalog and source-document naming convention.
|
|
4
|
+
* every request.
|
|
6
5
|
*/
|
|
7
6
|
export declare class KnowledgeSpatialCursorError extends Error {
|
|
8
7
|
}
|
|
9
8
|
export declare function encodeNearbyCursor(snapshot: string, offset: number): string;
|
|
10
9
|
export declare function nearbyCursorOffset(cursor: string | undefined, snapshot: string): number;
|
|
11
|
-
export declare function
|
|
10
|
+
export declare function spatialDocumentIdentity(publisherId: string, catalogId: string, documentId: string): string;
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Radius pagination binds offsets to the authorized catalog snapshot and
|
|
3
3
|
* query. Cursors confer no authority; the manager recalculates scope on
|
|
4
|
-
* every request.
|
|
5
|
-
* declared regional catalog and source-document naming convention.
|
|
4
|
+
* every request.
|
|
6
5
|
*/
|
|
7
6
|
export class KnowledgeSpatialCursorError extends Error {
|
|
8
7
|
}
|
|
@@ -24,10 +23,17 @@ export function nearbyCursorOffset(cursor, snapshot) {
|
|
|
24
23
|
throw new KnowledgeSpatialCursorError('Invalid or stale nearby cursor; restart the query.');
|
|
25
24
|
}
|
|
26
25
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Source-article ids (`wikipedia-<page-id>`) name the same article in every
|
|
28
|
+
* catalog of one publisher, and regional catalogs overlap heavily — Pacific
|
|
29
|
+
* Northwest and Canada & Alaska share 683 of the former's 1,106 articles — so
|
|
30
|
+
* those copies are one result. Any other id is unique only within its catalog.
|
|
31
|
+
* Keyed on the id, not the catalog name: an earlier catalog-name match never
|
|
32
|
+
* fired for the published `regional-*` catalogs, so every copy came through.
|
|
33
|
+
*/
|
|
34
|
+
const SOURCE_ARTICLE_ID = /^(wikipedia|wikivoyage)-[0-9]+$/;
|
|
35
|
+
export function spatialDocumentIdentity(publisherId, catalogId, documentId) {
|
|
36
|
+
return SOURCE_ARTICLE_ID.test(documentId)
|
|
31
37
|
? `${publisherId}\u0000${documentId}`
|
|
32
38
|
: `${publisherId}\u0000${catalogId}\u0000${documentId}`;
|
|
33
39
|
}
|
|
@@ -141,6 +141,8 @@ export function createWorkerCatalogHost() {
|
|
|
141
141
|
documentsPage: async (key, opts) =>
|
|
142
142
|
// biome-ignore lint/suspicious/noExplicitAny: structured-clone round trip preserves the shape
|
|
143
143
|
(await call('documentsPage', [key, opts])),
|
|
144
|
+
documentInitials: async (key, opts) => (await call('documentInitials', [key, opts])),
|
|
145
|
+
documentPosition: async (key, documentId, opts) => (await call('documentPosition', [key, documentId, opts])),
|
|
144
146
|
nearbyDocuments: async (key, radius, opts) => (await call('nearbyDocuments', [key, radius, opts])),
|
|
145
147
|
getDocument: async (key, documentId) =>
|
|
146
148
|
// biome-ignore lint/suspicious/noExplicitAny: structured-clone round trip preserves the shape
|