@openspecui/server 3.7.2 → 3.9.0

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.
Files changed (2) hide show
  1. package/dist/index.mjs +2172 -33
  2. package/package.json +7 -1
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { CliExecutor, CodeEditorThemeSchema, ConfigManager, CustomSoundHashSchema, DASHBOARD_METRIC_KEYS, DashboardConfigSchema, DocumentTranslationConfigSchema, GitConfigSchema, GlobalSettingsManager, MarkdownParser, NotificationPublishInputSchema, NotificationSettingsSchema, OPENSPECUI_HOOKS_VERSION, OpenSpecAdapter, OpenSpecUIGlobalSettingsSchema, OpenSpecWatcher, OpsxConfigSchema, OpsxKernel, PtyClientMessageSchema, ReactiveContext, TerminalConfigSchema, TerminalControlParser, TerminalRendererEngineSchema, TranslationCacheReadInputSchema, TranslationCacheSettingsSchema, TranslationCacheWriteInputSchema, buildBackendHealthPayload, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, getDetectedProjectTools, getToolInitStates, getWatcherRuntimeStatus, initWatcherPool, isWatcherPoolInitialized, parseOpsxEntityMetadata, parseOpsxSchemaDetail, resolveTerminalShellDefaults, sniffGlobalCli, subscribeWatcherRuntimeStatus, terminalNotificationEventToPublishInput } from "@openspecui/core";
1
+ import { BatchTranslateInputSchema, CliExecutor, CodeEditorThemeSchema, ConfigManager, CustomSoundHashSchema, DASHBOARD_METRIC_KEYS, DashboardConfigSchema, DocumentTranslationConfigSchema, GitConfigSchema, GlobalSettingsManager, LocalModelAssetStateSchema, MarkdownParser, NotificationPublishInputSchema, NotificationSettingsSchema, OPENSPECUI_HOOKS_VERSION, OpenSpecAdapter, OpenSpecUIGlobalSettingsSchema, OpenSpecWatcher, OpsxConfigSchema, OpsxKernel, PtyClientMessageSchema, ReactiveContext, ServiceTranslationEngineIdSchema, TRANSLATION_ENGINE_MANIFESTS, TerminalConfigSchema, TerminalControlParser, TerminalRendererEngineSchema, TranslationCacheReadInputSchema, TranslationCacheSettingsSchema, TranslationCacheWriteInputSchema, TranslationEngineIdSchema, TranslationLocalSettingsSchema, TranslationOpenAISettingsSchema, buildBackendHealthPayload, buildLocalDownloadPlanFromRepositoryFiles, clearCache, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, getDetectedProjectTools, getToolInitStates, getWatcherRuntimeStatus, initWatcherPool, isWatcherPoolInitialized, parseOpsxEntityMetadata, parseOpsxSchemaDetail, resolveTerminalShellDefaults, selectLocalDownloadGroup, sniffGlobalCli, subscribeWatcherRuntimeStatus, terminalNotificationEventToPublishInput } from "@openspecui/core";
2
2
  import { basename, dirname, join, matchesGlob, relative, resolve, sep } from "node:path";
3
- import { access, mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
3
+ import { access, copyFile, lstat, mkdir, open, readFile, readlink, realpath, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { createServer as createServer$1 } from "node:net";
6
6
  import { serve } from "@hono/node-server";
@@ -8,7 +8,7 @@ import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
8
8
  import { applyWSSHandler } from "@trpc/server/adapters/ws";
9
9
  import { Hono } from "hono";
10
10
  import { cors } from "hono/cors";
11
- import { readFileSync } from "node:fs";
11
+ import { existsSync, readFileSync } from "node:fs";
12
12
  import { WebSocketServer } from "ws";
13
13
  import { CustomSoundHashSchema as CustomSoundHashSchema$1, CustomSoundIdSchema, CustomSoundMetadataFileSchema, customHashFromSoundId, soundIdFromCustomHash } from "@openspecui/core/sounds";
14
14
  import { createHash } from "node:crypto";
@@ -16,13 +16,15 @@ import { homedir, platform } from "node:os";
16
16
  import { EventEmitter } from "node:events";
17
17
  import { execFile } from "node:child_process";
18
18
  import { promisify } from "node:util";
19
+ import { downloadFile, fileDownloadInfo, listFiles } from "@huggingface/hub";
20
+ import { observable } from "@trpc/server/observable";
21
+ import { z } from "zod";
22
+ import { Agent, EnvHttpProxyAgent, setGlobalDispatcher } from "undici";
19
23
  import { NotificationGroupKeySchema, NotificationPublishInputSchema as NotificationPublishInputSchema$1, getNotificationGroupKey } from "@openspecui/core/notifications";
20
24
  import * as pty from "@lydell/node-pty";
21
25
  import { EventEmitter as EventEmitter$1 } from "events";
22
26
  import { SearchQuerySchema } from "@openspecui/search";
23
27
  import { initTRPC } from "@trpc/server";
24
- import { observable } from "@trpc/server/observable";
25
- import { z } from "zod";
26
28
  import { NodeWorkerSearchProvider } from "@openspecui/search/node";
27
29
 
28
30
  //#region src/document-service.ts
@@ -355,7 +357,7 @@ function normalizeHooksModule(moduleValue) {
355
357
  onRunWorkflow: isOnRunWorkflowHook(record.onRunWorkflow) ? record.onRunWorkflow : isOnRunWorkflowHook(defaultRecord.onRunWorkflow) ? defaultRecord.onRunWorkflow : isOnRunWorkflowHook(moduleExportsRecord.onRunWorkflow) ? moduleExportsRecord.onRunWorkflow : void 0
356
358
  };
357
359
  }
358
- async function pathExists$1(path) {
360
+ async function pathExists$2(path) {
359
361
  try {
360
362
  await access(path);
361
363
  return true;
@@ -384,7 +386,7 @@ var ProjectHookRuntime = class {
384
386
  await Promise.allSettled(callbacks.map((cleanup) => cleanup()));
385
387
  }
386
388
  async loadFresh() {
387
- if (!await pathExists$1(this.hooksPath)) return {};
389
+ if (!await pathExists$2(this.hooksPath)) return {};
388
390
  const { tsImport } = await import("tsx/esm/api");
389
391
  return normalizeHooksModule(await tsImport(`${pathToFileURL(this.hooksPath).href}?t=${Date.now()}`, { parentURL: pathToFileURL(this.hooksPath).href }));
390
392
  }
@@ -1448,6 +1450,1783 @@ async function buildEntityReadOptions(ctx, stage, id) {
1448
1450
  }
1449
1451
  }
1450
1452
 
1453
+ //#endregion
1454
+ //#region src/huggingface-endpoint.ts
1455
+ const DEFAULT_HUGGING_FACE_ENDPOINT = "https://huggingface.co";
1456
+ function normalizeHuggingFaceEndpoint(endpoint) {
1457
+ const trimmed = endpoint?.trim();
1458
+ if (!trimmed) return DEFAULT_HUGGING_FACE_ENDPOINT;
1459
+ return trimmed.replace(/\/+$/, "");
1460
+ }
1461
+ function buildHuggingFaceApiBaseUrl(endpoint) {
1462
+ return `${normalizeHuggingFaceEndpoint(endpoint)}/api`;
1463
+ }
1464
+ function buildTransformersRemoteHost(endpoint) {
1465
+ return `${normalizeHuggingFaceEndpoint(endpoint)}/`;
1466
+ }
1467
+
1468
+ //#endregion
1469
+ //#region src/local-model-asset-store.ts
1470
+ const LocalModelAssetIndexSchema = LocalModelAssetStateSchema.array();
1471
+ var LocalModelAssetStore = class {
1472
+ constructor(options) {
1473
+ this.options = options;
1474
+ }
1475
+ getIndexPath() {
1476
+ return this.options.indexPath;
1477
+ }
1478
+ async readAll() {
1479
+ try {
1480
+ const content = await readFile(this.options.indexPath, "utf8");
1481
+ const parsed = JSON.parse(content);
1482
+ const result = LocalModelAssetIndexSchema.safeParse(parsed);
1483
+ return result.success ? result.data : [];
1484
+ } catch {
1485
+ return [];
1486
+ }
1487
+ }
1488
+ async readMap() {
1489
+ return new Map((await this.readAll()).map((state) => [state.modelId, state]));
1490
+ }
1491
+ async writeAll(states) {
1492
+ const normalized = LocalModelAssetIndexSchema.parse([...states].sort((left, right) => left.modelId.localeCompare(right.modelId)));
1493
+ const serialized = JSON.stringify(normalized, null, 2);
1494
+ await mkdir(dirname(this.options.indexPath), { recursive: true });
1495
+ const tempPath = `${this.options.indexPath}.${process.pid}.${Date.now()}.tmp`;
1496
+ await writeFile(tempPath, `${serialized}\n`, "utf8");
1497
+ await rename(tempPath, this.options.indexPath);
1498
+ clearCache();
1499
+ }
1500
+ async upsert(state) {
1501
+ const states = await this.readMap();
1502
+ states.set(state.modelId, LocalModelAssetStateSchema.parse(state));
1503
+ await this.writeAll([...states.values()]);
1504
+ }
1505
+ async remove(modelId) {
1506
+ const states = await this.readMap();
1507
+ if (!states.delete(modelId)) return;
1508
+ await this.writeAll([...states.values()]);
1509
+ }
1510
+ };
1511
+
1512
+ //#endregion
1513
+ //#region src/translation-cache-path.ts
1514
+ function getDefaultTranslationCacheDatabasePath() {
1515
+ return join(getOpenSpecUICacheDir(), "translation-cache.sqlite");
1516
+ }
1517
+ function getOpenSpecUICacheDir() {
1518
+ const currentPlatform = platform();
1519
+ if (currentPlatform === "darwin") return join(homedir(), "Library", "Caches", "openspecui");
1520
+ if (currentPlatform === "win32") return join(process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local"), "OpenSpecUI", "Cache");
1521
+ return join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "openspecui");
1522
+ }
1523
+
1524
+ //#endregion
1525
+ //#region src/local-model-cache-path.ts
1526
+ function getDefaultLocalModelCacheRoot() {
1527
+ return join(getOpenSpecUICacheDir(), "translation-engines", "local");
1528
+ }
1529
+ function getDefaultLocalModelCacheDir() {
1530
+ return join(getDefaultLocalModelCacheRoot(), "hf-cache");
1531
+ }
1532
+ function getDefaultLocalModelIndexPath() {
1533
+ return join(getDefaultLocalModelCacheRoot(), "models.json");
1534
+ }
1535
+ function getDefaultLocalModelFetchCachePath() {
1536
+ return join(getDefaultLocalModelCacheRoot(), "fetch-cache.json");
1537
+ }
1538
+
1539
+ //#endregion
1540
+ //#region src/local-model-fetch-cache-store.ts
1541
+ const RawJsonRecordSchema = z.record(z.string(), z.unknown());
1542
+ const HttpHeaderRecordSchema = z.record(z.string(), z.string());
1543
+ const LocalModelFetchCacheQueryContextSchema = z.object({
1544
+ query: z.string().optional(),
1545
+ sourceLanguage: z.string().optional(),
1546
+ targetLanguage: z.string().optional()
1547
+ });
1548
+ const LocalModelProviderFetchRecordSchema = z.object({
1549
+ id: z.string().min(1),
1550
+ source: z.literal("huggingface"),
1551
+ fetchedAt: z.number().int().nonnegative(),
1552
+ request: z.object({
1553
+ method: z.literal("GET"),
1554
+ url: z.string().min(1),
1555
+ queryContext: LocalModelFetchCacheQueryContextSchema.optional()
1556
+ }),
1557
+ response: z.object({
1558
+ status: z.number().int().nonnegative(),
1559
+ ok: z.boolean(),
1560
+ headers: HttpHeaderRecordSchema,
1561
+ bodyText: z.string()
1562
+ })
1563
+ });
1564
+ const LocalModelFetchCacheRecordSchema = z.object({
1565
+ modelId: z.string().min(1),
1566
+ source: z.literal("huggingface"),
1567
+ listItemRaw: RawJsonRecordSchema.optional(),
1568
+ detailRaw: RawJsonRecordSchema.optional(),
1569
+ queryContext: LocalModelFetchCacheQueryContextSchema.optional(),
1570
+ listFetchedAt: z.number().int().nonnegative().optional(),
1571
+ detailFetchedAt: z.number().int().nonnegative().optional(),
1572
+ updatedAt: z.number().int().nonnegative()
1573
+ });
1574
+ const LocalModelFetchCacheFileSchema = z.object({
1575
+ version: z.literal(1).default(1),
1576
+ fetches: z.array(LocalModelProviderFetchRecordSchema).default([]),
1577
+ records: z.array(LocalModelFetchCacheRecordSchema).default([])
1578
+ });
1579
+ var LocalModelFetchCacheStore = class {
1580
+ now;
1581
+ constructor(options) {
1582
+ this.options = options;
1583
+ this.now = options.now ?? Date.now;
1584
+ }
1585
+ async readAll() {
1586
+ return (await this.readFile()).records;
1587
+ }
1588
+ async readFetches() {
1589
+ return (await this.readFile()).fetches;
1590
+ }
1591
+ async readFile() {
1592
+ try {
1593
+ const content = await readFile(this.options.cachePath, "utf8");
1594
+ const parsed = JSON.parse(content);
1595
+ const result = LocalModelFetchCacheFileSchema.safeParse(parsed);
1596
+ return result.success ? result.data : LocalModelFetchCacheFileSchema.parse({});
1597
+ } catch {
1598
+ return LocalModelFetchCacheFileSchema.parse({});
1599
+ }
1600
+ }
1601
+ async read(modelId) {
1602
+ return (await this.readMap()).get(modelId) ?? null;
1603
+ }
1604
+ async readMap() {
1605
+ return new Map((await this.readAll()).map((record) => [record.modelId, record]));
1606
+ }
1607
+ async writeAll(records) {
1608
+ const current = await this.readFile();
1609
+ await this.writeFile({
1610
+ fetches: current.fetches,
1611
+ records
1612
+ });
1613
+ }
1614
+ async writeFile(input) {
1615
+ const normalized = LocalModelFetchCacheFileSchema.parse({
1616
+ version: 1,
1617
+ fetches: [...input.fetches].sort((left, right) => left.id.localeCompare(right.id)),
1618
+ records: [...input.records].sort((left, right) => left.modelId.localeCompare(right.modelId))
1619
+ });
1620
+ await mkdir(dirname(this.options.cachePath), { recursive: true });
1621
+ await writeFile(this.options.cachePath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
1622
+ clearCache();
1623
+ }
1624
+ async upsertProviderFetch(input) {
1625
+ const current = await this.readFile();
1626
+ const id = buildProviderFetchId(input.url);
1627
+ const fetches = new Map(current.fetches.map((record) => [record.id, record]));
1628
+ fetches.set(id, LocalModelProviderFetchRecordSchema.parse({
1629
+ id,
1630
+ source: "huggingface",
1631
+ fetchedAt: this.now(),
1632
+ request: {
1633
+ method: "GET",
1634
+ url: input.url,
1635
+ queryContext: input.queryContext
1636
+ },
1637
+ response: {
1638
+ status: input.status,
1639
+ ok: input.ok,
1640
+ headers: input.headers,
1641
+ bodyText: input.bodyText
1642
+ }
1643
+ }));
1644
+ await this.writeFile({
1645
+ fetches: [...fetches.values()],
1646
+ records: current.records
1647
+ });
1648
+ }
1649
+ async upsertListItem(input) {
1650
+ const records = await this.readMap();
1651
+ const current = records.get(input.modelId);
1652
+ records.set(input.modelId, LocalModelFetchCacheRecordSchema.parse({
1653
+ ...current,
1654
+ modelId: input.modelId,
1655
+ source: "huggingface",
1656
+ listItemRaw: input.raw,
1657
+ queryContext: input.queryContext ?? current?.queryContext,
1658
+ listFetchedAt: this.now(),
1659
+ updatedAt: this.now()
1660
+ }));
1661
+ await this.writeAll([...records.values()]);
1662
+ }
1663
+ async upsertDetail(input) {
1664
+ const records = await this.readMap();
1665
+ const current = records.get(input.modelId);
1666
+ records.set(input.modelId, LocalModelFetchCacheRecordSchema.parse({
1667
+ ...current,
1668
+ modelId: input.modelId,
1669
+ source: "huggingface",
1670
+ detailRaw: input.raw,
1671
+ queryContext: input.queryContext ?? current?.queryContext,
1672
+ detailFetchedAt: this.now(),
1673
+ updatedAt: this.now()
1674
+ }));
1675
+ await this.writeAll([...records.values()]);
1676
+ }
1677
+ };
1678
+ function buildProviderFetchId(url) {
1679
+ return `huggingface:GET:${url}`;
1680
+ }
1681
+
1682
+ //#endregion
1683
+ //#region src/local-model-local-cache.ts
1684
+ function getTransformersLocalModelPath(cacheDir, modelId) {
1685
+ return join(cacheDir, "models", modelId);
1686
+ }
1687
+ function getTransformersFileCacheModelPath(cacheDir, modelId) {
1688
+ return join(cacheDir, modelId);
1689
+ }
1690
+ async function readLocalModelFileStatus(input) {
1691
+ const files = await Promise.all(input.files.map(async (file) => ({
1692
+ file,
1693
+ cached: await pathExists$1(join(getTransformersLocalModelPath(input.cacheDir, input.modelId), file)) || await pathExists$1(join(getTransformersFileCacheModelPath(input.cacheDir, input.modelId), file))
1694
+ })));
1695
+ return {
1696
+ allCached: files.length > 0 && files.every((file) => file.cached),
1697
+ files
1698
+ };
1699
+ }
1700
+ async function pathExists$1(path) {
1701
+ return stat(path).then(() => true, () => false);
1702
+ }
1703
+
1704
+ //#endregion
1705
+ //#region src/local-model-runtime.ts
1706
+ async function configureTransformersRuntime(transformers, cacheDir) {
1707
+ await mkdir(cacheDir, { recursive: true });
1708
+ transformers.env.cacheDir = cacheDir;
1709
+ transformers.env.allowLocalModels = false;
1710
+ transformers.env.localModelPath = join(cacheDir, "models");
1711
+ }
1712
+ async function resolveLocalModelRuntimePlan(input) {
1713
+ await configureTransformersRuntime(input.transformers, input.cacheDir);
1714
+ const hubUrl = normalizeHuggingFaceEndpoint(input.hfEndpoint);
1715
+ const repositoryFiles = await readHuggingFaceRepositoryFiles({
1716
+ selectedGroupId: input.selectedGroupId,
1717
+ modelId: input.modelId,
1718
+ hubUrl,
1719
+ fetchCacheStore: input.fetchCacheStore
1720
+ });
1721
+ return buildLocalDownloadPlanFromRepositoryFiles({
1722
+ modelId: input.modelId,
1723
+ selectedGroupId: input.selectedGroupId,
1724
+ files: repositoryFiles
1725
+ });
1726
+ }
1727
+ async function resolveLocalModelRuntimePlanFromProject(input) {
1728
+ const transformers = await (input.loadTransformersModule ?? loadLocalTransformersModule)(input.projectDir, input.globalSettingsManager);
1729
+ const settings = await input.globalSettingsManager.readSettings();
1730
+ return resolveLocalModelRuntimePlan({
1731
+ modelId: input.modelId,
1732
+ transformers,
1733
+ cacheDir: input.cacheDir ?? getDefaultLocalModelCacheDir(),
1734
+ selectedGroupId: input.selectedGroupId,
1735
+ hfEndpoint: settings.translationEngines.local?.hfEndpoint,
1736
+ fetchCacheStore: input.fetchCacheStore
1737
+ });
1738
+ }
1739
+ async function readHuggingFaceRepositoryFiles(input) {
1740
+ let lastError;
1741
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1742
+ try {
1743
+ const files = [];
1744
+ for await (const entry of listFiles({
1745
+ repo: {
1746
+ type: "model",
1747
+ name: input.modelId
1748
+ },
1749
+ recursive: true,
1750
+ expand: true,
1751
+ hubUrl: input.hubUrl,
1752
+ fetch: input.fetchCacheStore ? createProviderFetchCache(input.fetchCacheStore) : void 0
1753
+ })) {
1754
+ if (entry.type !== "file") continue;
1755
+ files.push({
1756
+ path: entry.path,
1757
+ sizeBytes: entry.lfs?.size ?? entry.size
1758
+ });
1759
+ }
1760
+ if (files.length > 0) return files;
1761
+ lastError = /* @__PURE__ */ new Error(`No repository files were returned for ${input.modelId}.`);
1762
+ } catch (error) {
1763
+ lastError = error;
1764
+ }
1765
+ if (attempt < 2) await delay$2(300 * (attempt + 1));
1766
+ }
1767
+ const cachedFiles = await readCachedHuggingFaceRepositoryFiles(input);
1768
+ if (cachedFiles.length > 0) return cachedFiles;
1769
+ throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error(`Unable to read repository files for ${input.modelId}.`);
1770
+ }
1771
+ async function readCachedHuggingFaceRepositoryFiles(input) {
1772
+ if (!input.fetchCacheStore) return [];
1773
+ const record = await input.fetchCacheStore.read(input.modelId);
1774
+ if (!record?.detailRaw) return [];
1775
+ return extractRepositoryFilesFromCachedDetail(record.detailRaw);
1776
+ }
1777
+ function extractRepositoryFilesFromCachedDetail(raw) {
1778
+ const siblings = Array.isArray(raw.siblings) ? raw.siblings : [];
1779
+ const files = [];
1780
+ for (const sibling of siblings) {
1781
+ if (!sibling || typeof sibling !== "object") continue;
1782
+ const record = sibling;
1783
+ const path = typeof record.rfilename === "string" ? record.rfilename : null;
1784
+ if (!path) continue;
1785
+ files.push({
1786
+ path,
1787
+ sizeBytes: typeof record.size === "number" && Number.isFinite(record.size) ? record.size : void 0
1788
+ });
1789
+ }
1790
+ return files;
1791
+ }
1792
+ function createProviderFetchCache(fetchCacheStore) {
1793
+ return async (input, init) => {
1794
+ const response = await fetch(input, init);
1795
+ const url = normalizeRequestUrl(input);
1796
+ if (!url.includes("/api/models/") || !url.includes("/tree/")) return response;
1797
+ await fetchCacheStore.upsertProviderFetch({
1798
+ url,
1799
+ status: response.status,
1800
+ ok: response.ok,
1801
+ headers: headersToRecord$1(response.headers),
1802
+ bodyText: await response.clone().text()
1803
+ });
1804
+ return response;
1805
+ };
1806
+ }
1807
+ function normalizeRequestUrl(input) {
1808
+ if (typeof input === "string") return input;
1809
+ if (input instanceof URL) return input.href;
1810
+ return input.url;
1811
+ }
1812
+ function headersToRecord$1(headers) {
1813
+ return Object.fromEntries(headers.entries());
1814
+ }
1815
+ function delay$2(ms) {
1816
+ return new Promise((resolve$1) => setTimeout(resolve$1, ms));
1817
+ }
1818
+ async function loadLocalTransformersModule(_projectDir, _globalSettingsManager) {
1819
+ return await import("@huggingface/transformers");
1820
+ }
1821
+
1822
+ //#endregion
1823
+ //#region src/network-dispatcher.ts
1824
+ const DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
1825
+ let sharedDispatcher = null;
1826
+ function createProxyAwareDispatcher() {
1827
+ if (hasProxyEnvironment()) return new EnvHttpProxyAgent({
1828
+ httpProxy: process.env.http_proxy ?? process.env.HTTP_PROXY,
1829
+ httpsProxy: process.env.https_proxy ?? process.env.HTTPS_PROXY,
1830
+ noProxy: process.env.no_proxy ?? process.env.NO_PROXY,
1831
+ connectTimeout: DEFAULT_CONNECT_TIMEOUT_MS
1832
+ });
1833
+ return new Agent({ connectTimeout: DEFAULT_CONNECT_TIMEOUT_MS });
1834
+ }
1835
+ function ensureProxyAwareFetchDispatcher() {
1836
+ if (sharedDispatcher) return sharedDispatcher;
1837
+ sharedDispatcher = createProxyAwareDispatcher();
1838
+ setGlobalDispatcher(sharedDispatcher);
1839
+ return sharedDispatcher;
1840
+ }
1841
+ function hasProxyEnvironment() {
1842
+ return Boolean(process.env.http_proxy || process.env.HTTP_PROXY || process.env.https_proxy || process.env.HTTPS_PROXY || process.env.all_proxy || process.env.ALL_PROXY);
1843
+ }
1844
+
1845
+ //#endregion
1846
+ //#region src/network-retry.ts
1847
+ const RETRYABLE_STATUS_CODES = new Set([
1848
+ 408,
1849
+ 409,
1850
+ 425,
1851
+ 429,
1852
+ 500,
1853
+ 502,
1854
+ 503,
1855
+ 504,
1856
+ 520,
1857
+ 521,
1858
+ 522,
1859
+ 523,
1860
+ 524
1861
+ ]);
1862
+ const RETRYABLE_ERROR_CODES = new Set([
1863
+ "ECONNABORTED",
1864
+ "ECONNREFUSED",
1865
+ "ECONNRESET",
1866
+ "EAI_AGAIN",
1867
+ "ENETDOWN",
1868
+ "ENETRESET",
1869
+ "ENETUNREACH",
1870
+ "ENOTFOUND",
1871
+ "ETIMEDOUT",
1872
+ "UND_ERR_BODY_TIMEOUT",
1873
+ "UND_ERR_CONNECT_TIMEOUT",
1874
+ "UND_ERR_HEADERS_TIMEOUT",
1875
+ "UND_ERR_SOCKET"
1876
+ ]);
1877
+ const RETRYABLE_MESSAGE_FRAGMENTS = [
1878
+ "fetch failed",
1879
+ "timeout",
1880
+ "timed out",
1881
+ "socket hang up",
1882
+ "econnreset",
1883
+ "econnrefused",
1884
+ "eai_again",
1885
+ "enotfound",
1886
+ "terminated",
1887
+ "too many requests",
1888
+ "rate limit",
1889
+ "bad gateway",
1890
+ "service unavailable",
1891
+ "gateway timeout",
1892
+ "temporarily unavailable",
1893
+ "connection reset",
1894
+ "connection refused"
1895
+ ];
1896
+ function isRetryableNetworkStatusCode(statusCode) {
1897
+ return RETRYABLE_STATUS_CODES.has(statusCode);
1898
+ }
1899
+ function isRetryableNetworkError(error, options = {}) {
1900
+ return isRetryableNetworkErrorInternal(error, options, /* @__PURE__ */ new Set());
1901
+ }
1902
+ function isRetryableNetworkErrorInternal(error, options, seen) {
1903
+ if (error === void 0 || error === null) return options.treatUnknownAsRetryable ?? false;
1904
+ if (typeof error === "string") return isRetryableNetworkMessage(error);
1905
+ if (typeof error !== "object") return options.treatUnknownAsRetryable ?? false;
1906
+ if (seen.has(error)) return false;
1907
+ seen.add(error);
1908
+ const record = error;
1909
+ if (record.name === "AbortError") return false;
1910
+ const statusCode = readNumericField(record, ["statusCode", "status"]);
1911
+ if (statusCode !== void 0 && isRetryableNetworkStatusCode(statusCode)) return true;
1912
+ const code = typeof record.code === "string" ? record.code.toUpperCase() : void 0;
1913
+ if (code && RETRYABLE_ERROR_CODES.has(code)) return true;
1914
+ if (typeof record.message === "string" && isRetryableNetworkMessage(record.message)) return true;
1915
+ if ("cause" in record) return isRetryableNetworkErrorInternal(record.cause, options, seen);
1916
+ return options.treatUnknownAsRetryable ?? false;
1917
+ }
1918
+ function readNumericField(record, keys) {
1919
+ for (const key of keys) {
1920
+ const value = record[key];
1921
+ if (typeof value === "number" && Number.isFinite(value)) return value;
1922
+ }
1923
+ }
1924
+ function isRetryableNetworkMessage(message) {
1925
+ const lower = message.toLowerCase();
1926
+ return RETRYABLE_MESSAGE_FRAGMENTS.some((fragment) => lower.includes(fragment)) || hasRetryableStatusInMessage(lower);
1927
+ }
1928
+ function hasRetryableStatusInMessage(message) {
1929
+ const statusMatch = message.match(/status\s+(\d{3})/);
1930
+ if (!statusMatch) return false;
1931
+ return isRetryableNetworkStatusCode(Number(statusMatch[1]));
1932
+ }
1933
+
1934
+ //#endregion
1935
+ //#region src/translation-model-catalog.ts
1936
+ const DEFAULT_SEARCH_LIMIT = 6;
1937
+ const MAX_SEARCH_FETCH_LIMIT = 12;
1938
+ const DEFAULT_SMALL_VERIFY_MODEL_ID = "Xenova/opus-mt-no-de";
1939
+ const HUGGING_FACE_FETCH_RETRY_COUNT = 2;
1940
+ const HUGGING_FACE_FETCH_RETRY_DELAY_MS = 750;
1941
+ const HUGGING_FACE_FETCH_DISPATCHER = createProxyAwareDispatcher();
1942
+ async function searchLocalModels(input, options = {}) {
1943
+ const list = await fetchHuggingFaceModelList(input, options);
1944
+ return {
1945
+ items: rankCandidates(await Promise.all(list.items.map(async (item) => {
1946
+ const detail = await getHuggingFaceModelDetail(item.id, input, options).catch(() => null);
1947
+ return detail ? toTranslationModelCandidate(detail, input) : toTranslationModelCandidate(item, input);
1948
+ })), input).slice(0, normalizeSearchLimit(input.limit)),
1949
+ nextCursor: list.nextCursor
1950
+ };
1951
+ }
1952
+ async function searchLocalModelsProgressively(input, options = {}) {
1953
+ const list = await fetchHuggingFaceModelList(input, options);
1954
+ const candidateShells = rankCandidates(list.items.map((item) => toTranslationModelCandidate(item, input)), input).slice(0, normalizeSearchLimit(input.limit));
1955
+ const events = [{
1956
+ requestId: input.requestId,
1957
+ phase: "candidates",
1958
+ items: candidateShells,
1959
+ nextCursor: list.nextCursor
1960
+ }];
1961
+ const enriched = await Promise.all(candidateShells.map(async (candidate) => {
1962
+ const detail = await getHuggingFaceModelDetail(candidate.id, input, options).catch(() => null);
1963
+ return detail ? toTranslationModelCandidate(detail, input) : candidate;
1964
+ }));
1965
+ events.push({
1966
+ requestId: input.requestId,
1967
+ phase: "enriched",
1968
+ items: rankCandidates(enriched, input).slice(0, normalizeSearchLimit(input.limit)),
1969
+ nextCursor: list.nextCursor
1970
+ });
1971
+ events.push({
1972
+ requestId: input.requestId,
1973
+ phase: "complete",
1974
+ items: rankCandidates(enriched, input).slice(0, normalizeSearchLimit(input.limit)),
1975
+ nextCursor: list.nextCursor
1976
+ });
1977
+ return events;
1978
+ }
1979
+ async function fetchHuggingFaceModelList(input, options) {
1980
+ const limit = normalizeSearchLimit(input.limit);
1981
+ const fetchLimit = Math.min(Math.max(limit * 2, limit), MAX_SEARCH_FETCH_LIMIT);
1982
+ const params = new URLSearchParams({
1983
+ pipeline_tag: "translation",
1984
+ sort: "trendingScore",
1985
+ direction: "-1",
1986
+ limit: String(fetchLimit)
1987
+ });
1988
+ if (input.query?.trim()) params.set("search", input.query.trim());
1989
+ if (input.cursor?.trim()) params.set("cursor", input.cursor.trim());
1990
+ const url = `${buildHuggingFaceApiBaseUrl(options.hfEndpoint)}/models?${params.toString()}`;
1991
+ const response = await fetchHuggingFace(url);
1992
+ const responseBody = await response.text();
1993
+ const fetchCacheStore = getFetchCacheStore(options);
1994
+ await fetchCacheStore.upsertProviderFetch({
1995
+ url,
1996
+ status: response.status,
1997
+ ok: response.ok,
1998
+ headers: headersToRecord(response.headers),
1999
+ bodyText: responseBody,
2000
+ queryContext: buildQueryContext(input)
2001
+ });
2002
+ if (!response.ok) throw new Error(`Hugging Face model search failed with status ${response.status}.`);
2003
+ const listJson = parseJson(responseBody);
2004
+ const rawItems = Array.isArray(listJson) ? listJson.filter(isRecord) : [];
2005
+ const items = rawItems.map(normalizeHfModelListItem).filter((item) => item !== null);
2006
+ for (const raw of rawItems) {
2007
+ const item = normalizeHfModelListItem(raw);
2008
+ if (!item) continue;
2009
+ await fetchCacheStore.upsertListItem({
2010
+ modelId: item.id,
2011
+ raw,
2012
+ queryContext: buildQueryContext(input)
2013
+ });
2014
+ }
2015
+ return {
2016
+ items,
2017
+ nextCursor: readNextCursor(response.headers.get("link"))
2018
+ };
2019
+ }
2020
+ async function getHuggingFaceModelDetail(modelId, input, options) {
2021
+ const [namespace, repo] = modelId.split("/", 2);
2022
+ const modelPath = namespace && repo ? `${encodeURIComponent(namespace)}/${encodeURIComponent(repo)}` : encodeURIComponent(modelId);
2023
+ const url = `${buildHuggingFaceApiBaseUrl(options.hfEndpoint)}/models/${modelPath}?blobs=true`;
2024
+ const response = await fetchHuggingFace(url);
2025
+ const responseBody = await response.text();
2026
+ await getFetchCacheStore(options).upsertProviderFetch({
2027
+ url,
2028
+ status: response.status,
2029
+ ok: response.ok,
2030
+ headers: headersToRecord(response.headers),
2031
+ bodyText: responseBody,
2032
+ queryContext: input ? buildQueryContext(input) : void 0
2033
+ });
2034
+ if (!response.ok) throw new Error(`Hugging Face model detail failed with status ${response.status}.`);
2035
+ const detailJson = parseJson(responseBody);
2036
+ const raw = isRecord(detailJson) ? detailJson : {};
2037
+ await getFetchCacheStore(options).upsertDetail({
2038
+ modelId,
2039
+ raw,
2040
+ queryContext: input ? buildQueryContext(input) : void 0
2041
+ });
2042
+ return normalizeHfModelDetail(detailJson, modelId);
2043
+ }
2044
+ function parseJson(text) {
2045
+ try {
2046
+ return JSON.parse(text);
2047
+ } catch {
2048
+ return null;
2049
+ }
2050
+ }
2051
+ function headersToRecord(headers) {
2052
+ return Object.fromEntries(headers.entries());
2053
+ }
2054
+ async function fetchHuggingFace(input) {
2055
+ let lastError;
2056
+ for (let attempt = 0; attempt <= HUGGING_FACE_FETCH_RETRY_COUNT; attempt += 1) {
2057
+ try {
2058
+ const response = await fetchWithDispatcher(input);
2059
+ if (response.ok || !isRetryableNetworkStatusCode(response.status)) return response;
2060
+ lastError = /* @__PURE__ */ new Error(`Hugging Face request failed with status ${response.status}.`);
2061
+ if (attempt === HUGGING_FACE_FETCH_RETRY_COUNT) return response;
2062
+ await response.body?.cancel().catch(() => void 0);
2063
+ } catch (error) {
2064
+ lastError = error;
2065
+ if (!isRetryableFetchError(error) || attempt === HUGGING_FACE_FETCH_RETRY_COUNT) throw error;
2066
+ }
2067
+ await delay$1(HUGGING_FACE_FETCH_RETRY_DELAY_MS * (attempt + 1));
2068
+ }
2069
+ throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error("Hugging Face request failed.");
2070
+ }
2071
+ async function fetchWithDispatcher(input) {
2072
+ return fetch(input, { dispatcher: HUGGING_FACE_FETCH_DISPATCHER });
2073
+ }
2074
+ function isRetryableFetchError(error) {
2075
+ return isRetryableNetworkError(error);
2076
+ }
2077
+ function delay$1(ms) {
2078
+ return new Promise((resolve$1) => {
2079
+ setTimeout(resolve$1, ms);
2080
+ });
2081
+ }
2082
+ function normalizeHfModelListItem(value) {
2083
+ if (!value || typeof value !== "object") return null;
2084
+ const record = value;
2085
+ if (typeof record.id !== "string" || record.id.length === 0) return null;
2086
+ return {
2087
+ id: record.id,
2088
+ pipeline_tag: typeof record.pipeline_tag === "string" ? record.pipeline_tag : void 0,
2089
+ tags: Array.isArray(record.tags) ? record.tags.filter(isString) : [],
2090
+ downloads: normalizeNonNegativeNumber(record.downloads),
2091
+ likes: normalizeNonNegativeNumber(record.likes),
2092
+ trendingScore: normalizeOptionalNumber(record.trendingScore),
2093
+ lastModified: typeof record.lastModified === "string" ? record.lastModified : void 0
2094
+ };
2095
+ }
2096
+ function normalizeHfModelDetail(value, fallbackId) {
2097
+ const base = normalizeHfModelListItem(value) ?? {
2098
+ id: fallbackId,
2099
+ tags: [],
2100
+ downloads: 0,
2101
+ likes: 0
2102
+ };
2103
+ const record = value && typeof value === "object" ? value : {};
2104
+ const configRecord = record.config && typeof record.config === "object" ? record.config : void 0;
2105
+ return {
2106
+ ...base,
2107
+ config: configRecord ? {
2108
+ is_encoder_decoder: typeof configRecord.is_encoder_decoder === "boolean" ? configRecord.is_encoder_decoder : void 0,
2109
+ model_type: typeof configRecord.model_type === "string" ? configRecord.model_type : void 0
2110
+ } : void 0,
2111
+ siblings: Array.isArray(record.siblings) ? record.siblings.map((entry) => normalizeSibling(entry)).filter((entry) => entry !== null) : []
2112
+ };
2113
+ }
2114
+ function normalizeSibling(value) {
2115
+ if (!value || typeof value !== "object") return null;
2116
+ const record = value;
2117
+ if (typeof record.rfilename !== "string" || record.rfilename.length === 0) return null;
2118
+ return {
2119
+ rfilename: record.rfilename,
2120
+ size: normalizeOptionalNumber(record.size)
2121
+ };
2122
+ }
2123
+ function toTranslationModelCandidate(detail, input) {
2124
+ const plan = isHfModelDetail(detail) ? resolveLocalModelPlan(detail) : null;
2125
+ const languageMatch = buildLanguageMatch(detail, input.sourceLanguage, input.targetLanguage);
2126
+ const compatibility = {
2127
+ transformersJs: detail.tags.includes("transformers.js"),
2128
+ onnx: detail.tags.includes("onnx"),
2129
+ localRuntimeVerified: detail.tags.includes("transformers.js") && detail.tags.includes("onnx")
2130
+ };
2131
+ const estimatedTotalBytes = plan?.estimatedTotalBytes;
2132
+ return {
2133
+ id: detail.id,
2134
+ label: detail.id,
2135
+ summary: buildCandidateSummary(detail, compatibility.localRuntimeVerified, estimatedTotalBytes),
2136
+ downloads: detail.downloads,
2137
+ likes: detail.likes,
2138
+ trendingScore: detail.trendingScore,
2139
+ lastModified: detail.lastModified,
2140
+ pipelineTag: detail.pipeline_tag,
2141
+ tags: detail.tags,
2142
+ compatibility,
2143
+ size: {
2144
+ estimatedTotalBytes,
2145
+ primaryBytes: estimatedTotalBytes
2146
+ },
2147
+ downloadGroups: plan?.groups,
2148
+ languageMatch
2149
+ };
2150
+ }
2151
+ function resolveLocalModelPlan(detail) {
2152
+ return buildLocalDownloadPlanFromRepositoryFiles({
2153
+ modelId: detail.id,
2154
+ isEncoderDecoder: detail.config?.is_encoder_decoder,
2155
+ files: detail.siblings.map((entry) => ({
2156
+ path: entry.rfilename,
2157
+ sizeBytes: entry.size
2158
+ }))
2159
+ });
2160
+ }
2161
+ function buildCandidateSummary(detail, verified, estimatedTotalBytes) {
2162
+ const parts = [verified ? "Verified Transformers.js + ONNX model." : "Translation model from Hugging Face."];
2163
+ if (estimatedTotalBytes !== void 0) parts.push(`Estimated download ${formatBytes$1(estimatedTotalBytes)}.`);
2164
+ if (detail.tags.includes("translation")) parts.push("Tagged for translation.");
2165
+ return parts.join(" ");
2166
+ }
2167
+ function buildLanguageMatch(detail, sourceLanguage, targetLanguage) {
2168
+ const sourceTokens = buildLanguageTokens(sourceLanguage);
2169
+ const targetTokens = buildLanguageTokens(targetLanguage);
2170
+ const searchable = [detail.id, ...detail.tags].join(" ").toLowerCase();
2171
+ const sourceMatched = sourceTokens.some((token) => searchable.includes(token));
2172
+ const targetMatched = targetTokens.some((token) => searchable.includes(token));
2173
+ let directionalScore = 0;
2174
+ if (sourceTokens.length > 0 && targetTokens.length > 0) {
2175
+ for (const sourceToken of sourceTokens) for (const targetToken of targetTokens) if (searchable.includes(`${sourceToken}-${targetToken}`) || searchable.includes(`${sourceToken}_${targetToken}`) || searchable.includes(`-${sourceToken}-${targetToken}`)) directionalScore = Math.max(directionalScore, 2);
2176
+ }
2177
+ if (targetMatched) directionalScore += 1;
2178
+ if (sourceMatched) directionalScore += .5;
2179
+ return {
2180
+ sourceMatched,
2181
+ targetMatched,
2182
+ directionalScore
2183
+ };
2184
+ }
2185
+ function buildLanguageTokens(language) {
2186
+ if (!language) return [];
2187
+ const normalized = language.trim().toLowerCase();
2188
+ if (!normalized) return [];
2189
+ const primary = normalized.split(/[-_]/, 1)[0] ?? normalized;
2190
+ return [...new Set([normalized, primary].filter(Boolean))];
2191
+ }
2192
+ function rankCandidate(candidate) {
2193
+ const smallVerifyBoost = candidate.id === DEFAULT_SMALL_VERIFY_MODEL_ID ? 18 : 0;
2194
+ const compatibilityBoost = candidate.compatibility.localRuntimeVerified ? 140 : -80;
2195
+ const trend = (candidate.trendingScore ?? 0) * 100;
2196
+ const directionalBoost = candidate.languageMatch.directionalScore * 45;
2197
+ const downloadsBoost = Math.log10(candidate.downloads + 1) * 12;
2198
+ const likesBoost = candidate.likes * .15;
2199
+ const sizePenalty = candidate.size.estimatedTotalBytes === void 0 ? 0 : Math.log10(candidate.size.estimatedTotalBytes / (1024 * 1024) + 1) * 12;
2200
+ return smallVerifyBoost + compatibilityBoost + trend + directionalBoost + downloadsBoost + likesBoost - sizePenalty;
2201
+ }
2202
+ function rankCandidates(candidates, input) {
2203
+ const verifiedCandidates = candidates.filter((candidate) => candidate.compatibility.localRuntimeVerified);
2204
+ return [...input.query?.trim() ? candidates : verifiedCandidates].sort((left, right) => rankCandidate(right) - rankCandidate(left));
2205
+ }
2206
+ function normalizeSearchLimit(limit) {
2207
+ return Math.min(Math.max(limit ?? DEFAULT_SEARCH_LIMIT, 1), 20);
2208
+ }
2209
+ function buildQueryContext(input) {
2210
+ return {
2211
+ ...input.query?.trim() ? { query: input.query.trim() } : {},
2212
+ ...input.sourceLanguage?.trim() ? { sourceLanguage: input.sourceLanguage.trim() } : {},
2213
+ ...input.targetLanguage?.trim() ? { targetLanguage: input.targetLanguage.trim() } : {}
2214
+ };
2215
+ }
2216
+ function getFetchCacheStore(options) {
2217
+ return options.fetchCacheStore ?? new LocalModelFetchCacheStore({ cachePath: getDefaultLocalModelFetchCachePath() });
2218
+ }
2219
+ function readNextCursor(linkHeader) {
2220
+ if (!linkHeader) return void 0;
2221
+ const match = /[?&]cursor=([^&>]+).*rel="next"/.exec(linkHeader);
2222
+ if (!match) return void 0;
2223
+ try {
2224
+ return decodeURIComponent(match[1]);
2225
+ } catch {
2226
+ return match[1];
2227
+ }
2228
+ }
2229
+ function formatBytes$1(value) {
2230
+ if (!Number.isFinite(value) || value <= 0) return "0 B";
2231
+ const units = [
2232
+ "B",
2233
+ "KB",
2234
+ "MB",
2235
+ "GB"
2236
+ ];
2237
+ let size = value;
2238
+ let unitIndex = 0;
2239
+ while (size >= 1024 && unitIndex < units.length - 1) {
2240
+ size /= 1024;
2241
+ unitIndex += 1;
2242
+ }
2243
+ const digits = size >= 100 || unitIndex === 0 ? 0 : 1;
2244
+ return `${size.toFixed(digits)} ${units[unitIndex]}`;
2245
+ }
2246
+ function normalizeNonNegativeNumber(value) {
2247
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
2248
+ }
2249
+ function normalizeOptionalNumber(value) {
2250
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
2251
+ }
2252
+ function isHfModelDetail(value) {
2253
+ return "siblings" in value;
2254
+ }
2255
+ function isString(value) {
2256
+ return typeof value === "string";
2257
+ }
2258
+ function isRecord(value) {
2259
+ return value !== null && typeof value === "object" && !Array.isArray(value);
2260
+ }
2261
+
2262
+ //#endregion
2263
+ //#region src/local-model-asset-service.ts
2264
+ const DEFAULT_NETWORK_RETRY_LIMIT = Number.POSITIVE_INFINITY;
2265
+ const DEFAULT_NETWORK_RETRY_DELAY_MS = 500;
2266
+ const DEFAULT_NETWORK_RETRY_DELAY_MAX_MS = 5e3;
2267
+ var LocalModelAssetService = class {
2268
+ now;
2269
+ store;
2270
+ cacheDir;
2271
+ fetchCacheStore;
2272
+ networkRetryPolicy;
2273
+ listeners = /* @__PURE__ */ new Set();
2274
+ sessions = /* @__PURE__ */ new Map();
2275
+ sessionTasks = /* @__PURE__ */ new Map();
2276
+ logs = /* @__PURE__ */ new Map();
2277
+ transformersModulePromise = null;
2278
+ constructor(options) {
2279
+ this.options = options;
2280
+ ensureProxyAwareFetchDispatcher();
2281
+ this.now = options.now ?? Date.now;
2282
+ this.cacheDir = options.cacheDir ?? getDefaultLocalModelCacheDir();
2283
+ this.networkRetryPolicy = {
2284
+ limit: options.networkRetryPolicy?.limit ?? DEFAULT_NETWORK_RETRY_LIMIT,
2285
+ delayMs: options.networkRetryPolicy?.delayMs ?? DEFAULT_NETWORK_RETRY_DELAY_MS,
2286
+ maxDelayMs: options.networkRetryPolicy?.maxDelayMs ?? DEFAULT_NETWORK_RETRY_DELAY_MAX_MS
2287
+ };
2288
+ this.store = new LocalModelAssetStore({ indexPath: options.indexPath ?? getDefaultLocalModelIndexPath() });
2289
+ this.fetchCacheStore = new LocalModelFetchCacheStore({
2290
+ cachePath: options.fetchCachePath ?? getDefaultLocalModelFetchCachePath(),
2291
+ now: this.now
2292
+ });
2293
+ }
2294
+ subscribeLogs() {
2295
+ return observable((emit) => {
2296
+ for (const log of this.logs.values()) emit.next(log);
2297
+ const listener = (log) => emit.next(log);
2298
+ this.listeners.add(listener);
2299
+ return () => {
2300
+ this.listeners.delete(listener);
2301
+ };
2302
+ });
2303
+ }
2304
+ async listLocalCatalog() {
2305
+ const localMap = await this.store.readMap();
2306
+ const items = await Promise.all([...localMap.values()].map(async (state) => {
2307
+ const asset = await this.refreshCachedState(state);
2308
+ return toCatalogItem({
2309
+ id: state.modelId,
2310
+ label: state.modelId,
2311
+ summary: state.plan?.estimatedTotalBytes !== void 0 ? `Previously selected local model. Estimated download ${formatBytes(state.plan.estimatedTotalBytes)}.` : "Previously selected local model.",
2312
+ downloads: 0,
2313
+ likes: 0,
2314
+ tags: ["local"],
2315
+ compatibility: {
2316
+ transformersJs: true,
2317
+ onnx: true,
2318
+ localRuntimeVerified: true
2319
+ },
2320
+ size: {
2321
+ estimatedTotalBytes: state.plan?.estimatedTotalBytes,
2322
+ primaryBytes: state.plan?.estimatedTotalBytes
2323
+ },
2324
+ downloadGroups: state.plan?.groups,
2325
+ languageMatch: {
2326
+ sourceMatched: false,
2327
+ targetMatched: false,
2328
+ directionalScore: 0
2329
+ }
2330
+ }, asset);
2331
+ }));
2332
+ items.sort(compareCatalogItems);
2333
+ return { items };
2334
+ }
2335
+ async searchRemoteCatalog(input) {
2336
+ const [remote, localMap, selectedModel] = await Promise.all([
2337
+ this.searchRemote(input),
2338
+ this.store.readMap(),
2339
+ this.readSelectedModel()
2340
+ ]);
2341
+ const items = await this.decorateCatalogItems(remote.items, localMap, selectedModel);
2342
+ items.sort(compareCatalogItems);
2343
+ return {
2344
+ items,
2345
+ nextCursor: remote.nextCursor
2346
+ };
2347
+ }
2348
+ subscribeRemoteCatalog(input) {
2349
+ return observable((emit) => {
2350
+ let active = true;
2351
+ (async () => {
2352
+ try {
2353
+ const events = await searchLocalModelsProgressively(input, {
2354
+ fetchCacheStore: this.fetchCacheStore,
2355
+ hfEndpoint: await this.readHuggingFaceEndpoint()
2356
+ });
2357
+ for (const event of events) {
2358
+ if (!active) return;
2359
+ const localMap = await this.store.readMap();
2360
+ const selectedModel = await this.readSelectedModel();
2361
+ const items = event.items ? await this.decorateCatalogItems(event.items, localMap, selectedModel, { includeLocalOnly: false }) : void 0;
2362
+ emit.next({
2363
+ requestId: event.requestId,
2364
+ phase: event.phase,
2365
+ items,
2366
+ nextCursor: event.nextCursor,
2367
+ message: event.message
2368
+ });
2369
+ }
2370
+ } catch (error) {
2371
+ if (!active) return;
2372
+ emit.next({
2373
+ requestId: input.requestId,
2374
+ phase: "error",
2375
+ message: error instanceof Error ? error.message : "Unable to search remote local models."
2376
+ });
2377
+ }
2378
+ })();
2379
+ return () => {
2380
+ active = false;
2381
+ };
2382
+ });
2383
+ }
2384
+ async listCatalog(input) {
2385
+ return this.searchRemoteCatalog(input);
2386
+ }
2387
+ async readSelectedModelState(modelId, selectedGroupId) {
2388
+ const state = (await this.store.readMap()).get(modelId);
2389
+ if (state) return this.refreshCachedState(state, selectedGroupId);
2390
+ const session = this.sessions.get(modelId);
2391
+ if (session) {
2392
+ const selected = modelId === await this.readSelectedModel();
2393
+ const plan = await this.readPlanForState(modelId, selectedGroupId ?? session.selectedGroupId);
2394
+ const selectedGroup = selectLocalDownloadGroup(plan, selectedGroupId ?? session.selectedGroupId);
2395
+ const files = (selectedGroup?.files ?? plan?.files ?? []).map((file) => ({
2396
+ path: file.path,
2397
+ sizeBytes: file.sizeBytes,
2398
+ downloadedBytes: 0
2399
+ }));
2400
+ return LocalModelAssetStateSchema.parse({
2401
+ modelId,
2402
+ plan: plan ?? void 0,
2403
+ status: "downloading",
2404
+ selected,
2405
+ resumable: true,
2406
+ totalBytes: selectedGroup?.estimatedTotalBytes ?? plan?.estimatedTotalBytes,
2407
+ progress: 0,
2408
+ files,
2409
+ updatedAt: this.now()
2410
+ });
2411
+ }
2412
+ return LocalModelAssetStateSchema.parse({
2413
+ modelId,
2414
+ status: "not-downloaded",
2415
+ selected: modelId === await this.readSelectedModel(),
2416
+ updatedAt: this.now()
2417
+ });
2418
+ }
2419
+ async startDownload(modelId, selectedGroupId) {
2420
+ return this.runDownload(modelId, "downloading", "Downloading local model", selectedGroupId);
2421
+ }
2422
+ async resumeDownload(modelId, selectedGroupId) {
2423
+ return this.runDownload(modelId, "downloading", "Resuming local model download", selectedGroupId);
2424
+ }
2425
+ async pauseDownload(modelId) {
2426
+ const session = this.sessions.get(modelId);
2427
+ if (session) {
2428
+ session.abortController.abort();
2429
+ this.sessions.delete(modelId);
2430
+ }
2431
+ const current = await this.readSelectedModelState(modelId);
2432
+ const nextState = LocalModelAssetStateSchema.parse({
2433
+ ...current,
2434
+ status: "paused",
2435
+ resumable: true,
2436
+ updatedAt: this.now()
2437
+ });
2438
+ await this.store.upsert(nextState);
2439
+ this.emitLog({
2440
+ engineId: "local",
2441
+ modelId,
2442
+ selectedGroupId: current.plan?.selectedGroupId,
2443
+ status: "paused",
2444
+ message: "Local model download paused.",
2445
+ progress: nextState.progress,
2446
+ bytesDownloaded: nextState.bytesDownloaded,
2447
+ totalBytes: nextState.totalBytes,
2448
+ resumable: true,
2449
+ files: nextState.files,
2450
+ updatedAt: this.now()
2451
+ });
2452
+ return { success: true };
2453
+ }
2454
+ async deleteModel(modelId) {
2455
+ this.sessions.get(modelId)?.abortController.abort();
2456
+ this.sessions.delete(modelId);
2457
+ const current = await this.readSelectedModelState(modelId);
2458
+ await this.store.upsert(LocalModelAssetStateSchema.parse({
2459
+ ...current,
2460
+ status: "deleting",
2461
+ updatedAt: this.now()
2462
+ }));
2463
+ this.emitLog({
2464
+ engineId: "local",
2465
+ modelId,
2466
+ selectedGroupId: current.plan?.selectedGroupId,
2467
+ status: "deleting",
2468
+ message: "Deleting local model files.",
2469
+ files: current.files,
2470
+ updatedAt: this.now()
2471
+ });
2472
+ await mkdir(this.cacheDir, { recursive: true });
2473
+ await rm(getTransformersLocalModelPath(this.cacheDir, modelId), {
2474
+ recursive: true,
2475
+ force: true
2476
+ });
2477
+ await rm(getTransformersFileCacheModelPath(this.cacheDir, modelId), {
2478
+ recursive: true,
2479
+ force: true
2480
+ });
2481
+ await rm(getHubCacheRepoPath(this.cacheDir, modelId), {
2482
+ recursive: true,
2483
+ force: true
2484
+ });
2485
+ await this.store.remove(modelId);
2486
+ this.emitLog({
2487
+ engineId: "local",
2488
+ modelId,
2489
+ selectedGroupId: current.plan?.selectedGroupId,
2490
+ status: "not-downloaded",
2491
+ message: "Local model files were removed.",
2492
+ progress: 0,
2493
+ bytesDownloaded: 0,
2494
+ totalBytes: 0,
2495
+ files: [],
2496
+ updatedAt: this.now()
2497
+ });
2498
+ return { success: true };
2499
+ }
2500
+ async markSelectedModel(modelId) {
2501
+ const nextStates = (await this.store.readAll()).map((state) => LocalModelAssetStateSchema.parse({
2502
+ ...state,
2503
+ selected: state.modelId === modelId
2504
+ }));
2505
+ if (!nextStates.some((state) => state.modelId === modelId)) nextStates.push(LocalModelAssetStateSchema.parse({
2506
+ modelId,
2507
+ status: "not-downloaded",
2508
+ selected: true,
2509
+ updatedAt: this.now()
2510
+ }));
2511
+ await this.store.writeAll(nextStates);
2512
+ }
2513
+ async waitForModelTask(modelId) {
2514
+ await this.sessionTasks.get(modelId);
2515
+ }
2516
+ async close() {
2517
+ const sessions = [...this.sessions.values()];
2518
+ for (const session of sessions) session.abortController.abort();
2519
+ await Promise.allSettled(this.sessionTasks.values());
2520
+ }
2521
+ async searchRemote(input) {
2522
+ return searchLocalModels({
2523
+ query: input.query,
2524
+ sourceLanguage: input.sourceLanguage,
2525
+ targetLanguage: input.targetLanguage,
2526
+ limit: input.limit,
2527
+ cursor: input.cursor
2528
+ }, {
2529
+ fetchCacheStore: this.fetchCacheStore,
2530
+ hfEndpoint: await this.readHuggingFaceEndpoint()
2531
+ });
2532
+ }
2533
+ async decorateCatalogItems(candidates, localMap, selectedModel, options = {}) {
2534
+ const seen = /* @__PURE__ */ new Set();
2535
+ const remoteItems = await Promise.all(candidates.map(async (candidate) => {
2536
+ seen.add(candidate.id);
2537
+ const localState = localMap.get(candidate.id);
2538
+ return toCatalogItem(candidate, localState ? await this.refreshCachedState(localState) : LocalModelAssetStateSchema.parse({
2539
+ modelId: candidate.id,
2540
+ status: "not-downloaded",
2541
+ selected: candidate.id === selectedModel,
2542
+ updatedAt: this.now()
2543
+ }));
2544
+ }));
2545
+ const localOnlyItems = options.includeLocalOnly === false ? [] : await Promise.all([...localMap.values()].filter((state) => !seen.has(state.modelId)).map(async (state) => {
2546
+ const asset = await this.refreshCachedState(state);
2547
+ return toCatalogItem({
2548
+ id: state.modelId,
2549
+ label: state.modelId,
2550
+ summary: state.plan?.estimatedTotalBytes !== void 0 ? `Previously selected local model. Estimated download ${formatBytes(state.plan.estimatedTotalBytes)}.` : "Previously selected local model.",
2551
+ downloads: 0,
2552
+ likes: 0,
2553
+ tags: ["local"],
2554
+ compatibility: {
2555
+ transformersJs: true,
2556
+ onnx: true,
2557
+ localRuntimeVerified: true
2558
+ },
2559
+ size: {
2560
+ estimatedTotalBytes: state.plan?.estimatedTotalBytes,
2561
+ primaryBytes: state.plan?.estimatedTotalBytes
2562
+ },
2563
+ downloadGroups: state.plan?.groups,
2564
+ languageMatch: {
2565
+ sourceMatched: false,
2566
+ targetMatched: false,
2567
+ directionalScore: 0
2568
+ }
2569
+ }, asset);
2570
+ }));
2571
+ return [...remoteItems, ...localOnlyItems];
2572
+ }
2573
+ async refreshCachedState(state, selectedGroupId) {
2574
+ const requestedGroupId = selectedGroupId;
2575
+ const session = this.sessions.get(state.modelId);
2576
+ const selected = state.selected || state.modelId === await this.readSelectedModel();
2577
+ if (state.status === "deleting") return LocalModelAssetStateSchema.parse({
2578
+ ...state,
2579
+ selected,
2580
+ updatedAt: this.now()
2581
+ });
2582
+ if (state.status === "downloaded" && state.plan && (requestedGroupId === void 0 || requestedGroupId === state.plan.selectedGroupId)) {
2583
+ const files$1 = (selectLocalDownloadGroup(state.plan, requestedGroupId ?? state.plan.selectedGroupId)?.files ?? state.plan.files).map((file) => ({
2584
+ path: file.path,
2585
+ sizeBytes: file.sizeBytes,
2586
+ downloadedBytes: file.sizeBytes
2587
+ }));
2588
+ return LocalModelAssetStateSchema.parse({
2589
+ ...state,
2590
+ selected,
2591
+ status: "downloaded",
2592
+ progress: 1,
2593
+ bytesDownloaded: state.totalBytes ?? state.plan.estimatedTotalBytes,
2594
+ totalBytes: state.totalBytes ?? state.plan.estimatedTotalBytes,
2595
+ resumable: false,
2596
+ error: void 0,
2597
+ files: files$1,
2598
+ updatedAt: this.now(),
2599
+ installedAt: state.installedAt ?? this.now()
2600
+ });
2601
+ }
2602
+ const transformers = await this.getTransformersModule();
2603
+ transformers.env.remoteHost = buildTransformersRemoteHost(await this.readHuggingFaceEndpoint());
2604
+ const [plan, persistedSelectedGroupId] = await Promise.all([this.readPlan(state.modelId, transformers, requestedGroupId ?? state.plan?.selectedGroupId), this.readSelectedGroupId()]);
2605
+ if (!plan && state.status !== "downloaded") return LocalModelAssetStateSchema.parse({
2606
+ ...state,
2607
+ selected,
2608
+ plan: void 0
2609
+ });
2610
+ const effectivePlan = plan ?? {
2611
+ modelId: state.modelId,
2612
+ estimatedTotalBytes: state.totalBytes,
2613
+ files: state.files.map((file) => ({
2614
+ path: file.path,
2615
+ sizeBytes: file.sizeBytes,
2616
+ required: true
2617
+ })),
2618
+ selectedGroupId: state.plan?.selectedGroupId,
2619
+ groups: state.plan?.groups
2620
+ };
2621
+ const selectedGroup = selectLocalDownloadGroup(effectivePlan, requestedGroupId ?? state.plan?.selectedGroupId ?? persistedSelectedGroupId);
2622
+ if (requestedGroupId && requestedGroupId !== effectivePlan.selectedGroupId && !selectedGroup) return LocalModelAssetStateSchema.parse({
2623
+ ...state,
2624
+ selected,
2625
+ status: "not-downloaded",
2626
+ progress: 0,
2627
+ bytesDownloaded: 0,
2628
+ totalBytes: void 0,
2629
+ resumable: false,
2630
+ files: [],
2631
+ updatedAt: this.now()
2632
+ });
2633
+ const planFiles = selectedGroup?.files ?? effectivePlan.files;
2634
+ const cacheStatus = await readLocalModelFileStatus({
2635
+ cacheDir: this.cacheDir,
2636
+ modelId: state.modelId,
2637
+ files: planFiles.map((file) => file.path)
2638
+ });
2639
+ const sameRequestedGroup = requestedGroupId === void 0 || requestedGroupId === state.plan?.selectedGroupId;
2640
+ const cachedFileSet = new Set(cacheStatus.files.filter((file) => file.cached).map((file) => file.file));
2641
+ const runtimeAllCached = cacheStatus.allCached;
2642
+ const files = planFiles.map((file) => {
2643
+ const cached = cachedFileSet.has(file.path);
2644
+ const existingDownloadedBytes = state.files.find((entry) => entry.path === file.path)?.downloadedBytes ?? 0;
2645
+ const downloadedBytes = file.sizeBytes === void 0 ? cached ? existingDownloadedBytes : existingDownloadedBytes : cached ? file.sizeBytes : Math.min(existingDownloadedBytes, file.sizeBytes);
2646
+ return {
2647
+ path: file.path,
2648
+ sizeBytes: file.sizeBytes,
2649
+ downloadedBytes
2650
+ };
2651
+ });
2652
+ const detectedBytesDownloaded = sumDownloadedBytes(files);
2653
+ const detectedProgress = effectivePlan.estimatedTotalBytes !== void 0 && effectivePlan.estimatedTotalBytes > 0 ? detectedBytesDownloaded / effectivePlan.estimatedTotalBytes : runtimeAllCached ? 1 : void 0;
2654
+ const progress = cacheStatus.allCached ? 1 : session ? state.progress : detectedProgress;
2655
+ const hasPartialCache = !runtimeAllCached && detectedBytesDownloaded > 0;
2656
+ return LocalModelAssetStateSchema.parse({
2657
+ ...state,
2658
+ selected,
2659
+ plan: effectivePlan,
2660
+ status: runtimeAllCached ? "downloaded" : session ? state.status : sameRequestedGroup && state.status === "paused" ? "paused" : sameRequestedGroup && state.status === "error" ? "error" : hasPartialCache ? "paused" : "not-downloaded",
2661
+ progress: progress === void 0 ? void 0 : Math.max(0, Math.min(1, progress)),
2662
+ totalBytes: effectivePlan.estimatedTotalBytes ?? state.totalBytes,
2663
+ bytesDownloaded: session ? state.bytesDownloaded : detectedBytesDownloaded,
2664
+ error: runtimeAllCached ? void 0 : state.error,
2665
+ resumable: runtimeAllCached ? false : sameRequestedGroup && state.status === "paused" || sameRequestedGroup && state.status === "error" || hasPartialCache || progress !== void 0 && progress > 0 && progress < 1,
2666
+ files,
2667
+ updatedAt: this.now(),
2668
+ installedAt: runtimeAllCached ? state.installedAt ?? this.now() : state.installedAt
2669
+ });
2670
+ }
2671
+ async runDownload(modelId, targetStatus, messagePrefix, selectedGroupId) {
2672
+ const existing = this.sessions.get(modelId);
2673
+ if (existing) return { sessionId: existing.sessionId };
2674
+ const sessionId = `local-model-${sanitizeId(modelId)}-${this.now()}`;
2675
+ const abortController = new AbortController();
2676
+ this.sessions.set(modelId, {
2677
+ modelId,
2678
+ sessionId,
2679
+ abortController,
2680
+ selectedGroupId
2681
+ });
2682
+ const current = await this.readSelectedModelState(modelId);
2683
+ const transformers = await this.getTransformersModule();
2684
+ const plan = await this.readPlan(modelId, transformers, selectedGroupId ?? current.plan?.selectedGroupId);
2685
+ if (!plan || plan.files.length === 0 || plan.estimatedTotalBytes === void 0) {
2686
+ this.sessions.delete(modelId);
2687
+ throw new Error("No concrete local model download plan is available.");
2688
+ }
2689
+ const totalBytes = plan.estimatedTotalBytes;
2690
+ const resumedFiles = buildDownloadStateFiles({
2691
+ planFiles: plan.files,
2692
+ currentFiles: current.files
2693
+ });
2694
+ const resumedBytesDownloaded = sumDownloadedBytes(resumedFiles);
2695
+ const nextState = LocalModelAssetStateSchema.parse({
2696
+ ...current,
2697
+ modelId,
2698
+ plan,
2699
+ status: targetStatus,
2700
+ selected: true,
2701
+ bytesDownloaded: resumedBytesDownloaded,
2702
+ progress: totalBytes > 0 ? resumedBytesDownloaded / totalBytes : current.progress,
2703
+ totalBytes,
2704
+ resumable: true,
2705
+ files: resumedFiles,
2706
+ updatedAt: this.now()
2707
+ });
2708
+ await this.store.upsert(nextState);
2709
+ this.emitLog({
2710
+ engineId: "local",
2711
+ modelId,
2712
+ selectedGroupId: nextState.plan?.selectedGroupId,
2713
+ status: targetStatus,
2714
+ message: `${messagePrefix} ${modelId}.`,
2715
+ progress: nextState.progress,
2716
+ bytesDownloaded: nextState.bytesDownloaded,
2717
+ totalBytes,
2718
+ sessionId,
2719
+ resumable: true,
2720
+ files: nextState.files,
2721
+ updatedAt: this.now()
2722
+ });
2723
+ const task = this.performDownload(modelId, sessionId, abortController.signal, nextState).catch((error) => this.finishDownload(modelId, sessionId, false, error instanceof Error ? error.message : String(error))).finally(() => {
2724
+ if (this.sessionTasks.get(modelId) === task) this.sessionTasks.delete(modelId);
2725
+ });
2726
+ this.sessionTasks.set(modelId, task);
2727
+ return { sessionId };
2728
+ }
2729
+ async performDownload(modelId, sessionId, signal, state) {
2730
+ const transformers = await this.getTransformersModule();
2731
+ await configureTransformersRuntime(transformers, this.cacheDir);
2732
+ transformers.env.remoteHost = buildTransformersRemoteHost(await this.readHuggingFaceEndpoint());
2733
+ const selectedGroup = selectLocalDownloadGroup(state.plan ?? null, state.plan?.selectedGroupId);
2734
+ const files = selectedGroup?.files ?? state.plan?.files ?? [];
2735
+ const totalBytes = selectedGroup?.estimatedTotalBytes ?? state.plan?.estimatedTotalBytes;
2736
+ const hfEndpoint = normalizeHuggingFaceEndpoint(await this.readHuggingFaceEndpoint());
2737
+ const downloadedFiles = buildDownloadStateFiles({
2738
+ planFiles: files,
2739
+ currentFiles: state.files
2740
+ });
2741
+ let bytesDownloaded = sumDownloadedBytes(downloadedFiles);
2742
+ if (files.length === 0) throw new Error("No concrete local model download files were selected.");
2743
+ for (const [fileIndex, file] of files.entries()) {
2744
+ throwIfAborted(signal);
2745
+ const previousFileBytes = downloadedFiles[fileIndex]?.downloadedBytes ?? 0;
2746
+ if (file.sizeBytes !== void 0 && previousFileBytes >= file.sizeBytes) continue;
2747
+ downloadedFiles[fileIndex] = {
2748
+ path: file.path,
2749
+ sizeBytes: file.sizeBytes,
2750
+ downloadedBytes: previousFileBytes
2751
+ };
2752
+ await this.emitDownloadProgress({
2753
+ modelId,
2754
+ sessionId,
2755
+ state,
2756
+ message: `Downloading ${file.path}.`,
2757
+ totalBytes,
2758
+ bytesDownloaded,
2759
+ files: downloadedFiles
2760
+ });
2761
+ const cachedPath = await downloadHuggingFaceFileToCacheDirWithProgress({
2762
+ repo: {
2763
+ type: "model",
2764
+ name: modelId
2765
+ },
2766
+ path: file.path,
2767
+ cacheDir: this.cacheDir,
2768
+ hubUrl: hfEndpoint,
2769
+ expectedSizeBytes: file.sizeBytes,
2770
+ retryPolicy: this.networkRetryPolicy,
2771
+ fetch: createAbortableFetch(signal),
2772
+ signal,
2773
+ onProgress: async (fileBytesDownloaded) => {
2774
+ throwIfAborted(signal);
2775
+ const boundedFileBytes = file.sizeBytes ? Math.min(file.sizeBytes, fileBytesDownloaded) : fileBytesDownloaded;
2776
+ downloadedFiles[fileIndex] = {
2777
+ path: file.path,
2778
+ sizeBytes: file.sizeBytes,
2779
+ downloadedBytes: boundedFileBytes
2780
+ };
2781
+ await this.emitDownloadProgress({
2782
+ modelId,
2783
+ sessionId,
2784
+ state,
2785
+ message: `Downloading ${file.path}.`,
2786
+ totalBytes,
2787
+ bytesDownloaded: bytesDownloaded - previousFileBytes + boundedFileBytes,
2788
+ files: downloadedFiles
2789
+ });
2790
+ },
2791
+ onRetry: async ({ retryDelayMs, phase }) => {
2792
+ const retryTarget = phase === "metadata" ? `metadata for ${file.path}` : `${file.path}`;
2793
+ await this.emitDownloadProgress({
2794
+ modelId,
2795
+ sessionId,
2796
+ state,
2797
+ message: `Connection interrupted while downloading ${retryTarget}. Retrying automatically in ${formatDuration(retryDelayMs)}.`,
2798
+ totalBytes,
2799
+ bytesDownloaded: bytesDownloaded - previousFileBytes + (downloadedFiles[fileIndex]?.downloadedBytes ?? 0),
2800
+ files: downloadedFiles
2801
+ });
2802
+ }
2803
+ });
2804
+ await mirrorHubCacheFileForTransformers({
2805
+ cacheDir: this.cacheDir,
2806
+ modelId,
2807
+ filePath: file.path,
2808
+ cachedPath
2809
+ });
2810
+ throwIfAborted(signal);
2811
+ const nextDownloadedBytes = file.sizeBytes ?? 0;
2812
+ bytesDownloaded = bytesDownloaded - previousFileBytes + nextDownloadedBytes;
2813
+ downloadedFiles[fileIndex] = {
2814
+ path: file.path,
2815
+ sizeBytes: file.sizeBytes,
2816
+ downloadedBytes: file.sizeBytes
2817
+ };
2818
+ await this.emitDownloadProgress({
2819
+ modelId,
2820
+ sessionId,
2821
+ state,
2822
+ message: `Downloaded ${file.path}.`,
2823
+ totalBytes,
2824
+ bytesDownloaded,
2825
+ files: downloadedFiles
2826
+ });
2827
+ }
2828
+ await this.finishDownload(modelId, sessionId, true, `Local model ${modelId} is ready.`);
2829
+ }
2830
+ async emitDownloadProgress(input) {
2831
+ if (!this.isActiveSession(input.modelId, input.sessionId)) return;
2832
+ const progress = input.totalBytes && input.totalBytes > 0 ? Math.max(0, Math.min(1, input.bytesDownloaded / input.totalBytes)) : void 0;
2833
+ const nextState = LocalModelAssetStateSchema.parse({
2834
+ ...input.state,
2835
+ status: "downloading",
2836
+ progress,
2837
+ bytesDownloaded: input.bytesDownloaded,
2838
+ totalBytes: input.totalBytes,
2839
+ files: input.files,
2840
+ updatedAt: this.now(),
2841
+ resumable: true
2842
+ });
2843
+ await this.store.upsert(nextState);
2844
+ this.emitLog({
2845
+ engineId: "local",
2846
+ modelId: input.modelId,
2847
+ selectedGroupId: input.state.plan?.selectedGroupId,
2848
+ status: "downloading",
2849
+ message: input.message,
2850
+ progress,
2851
+ bytesDownloaded: input.bytesDownloaded,
2852
+ totalBytes: input.totalBytes,
2853
+ files: input.files,
2854
+ sessionId: input.sessionId,
2855
+ resumable: true,
2856
+ updatedAt: this.now()
2857
+ });
2858
+ }
2859
+ async finishDownload(modelId, sessionId, success, message) {
2860
+ if (!this.isActiveSession(modelId, sessionId)) return;
2861
+ const current = await this.readSelectedModelState(modelId);
2862
+ const nextState = LocalModelAssetStateSchema.parse({
2863
+ ...current,
2864
+ status: success ? "downloaded" : "error",
2865
+ progress: success ? 1 : current.progress,
2866
+ bytesDownloaded: success ? current.totalBytes ?? current.bytesDownloaded : current.bytesDownloaded,
2867
+ totalBytes: current.totalBytes,
2868
+ installedAt: success ? this.now() : current.installedAt,
2869
+ updatedAt: this.now(),
2870
+ error: success ? void 0 : message,
2871
+ resumable: !success
2872
+ });
2873
+ await this.store.upsert(nextState);
2874
+ this.sessions.delete(modelId);
2875
+ this.emitLog({
2876
+ engineId: "local",
2877
+ modelId,
2878
+ selectedGroupId: nextState.plan?.selectedGroupId,
2879
+ status: nextState.status,
2880
+ message,
2881
+ progress: nextState.progress,
2882
+ bytesDownloaded: nextState.bytesDownloaded,
2883
+ totalBytes: nextState.totalBytes,
2884
+ sessionId,
2885
+ resumable: nextState.resumable,
2886
+ files: nextState.files,
2887
+ updatedAt: this.now()
2888
+ });
2889
+ }
2890
+ async readPlan(modelId, transformers, selectedGroupId) {
2891
+ return resolveLocalModelRuntimePlan({
2892
+ modelId,
2893
+ transformers,
2894
+ cacheDir: this.cacheDir,
2895
+ selectedGroupId: selectedGroupId ?? await this.readSelectedGroupId(),
2896
+ hfEndpoint: await this.readHuggingFaceEndpoint(),
2897
+ fetchCacheStore: this.fetchCacheStore
2898
+ }).catch(() => null);
2899
+ }
2900
+ async readPlanForState(modelId, selectedGroupId) {
2901
+ const transformers = await this.getTransformersModule();
2902
+ transformers.env.remoteHost = buildTransformersRemoteHost(await this.readHuggingFaceEndpoint());
2903
+ return this.readPlan(modelId, transformers, selectedGroupId);
2904
+ }
2905
+ async readSelectedModel() {
2906
+ return (await this.options.globalSettingsManager.readSettings()).translationEngines.local.model;
2907
+ }
2908
+ async readSelectedGroupId() {
2909
+ return (await this.options.globalSettingsManager.readSettings()).translationEngines.local.selectedGroupId;
2910
+ }
2911
+ async readHuggingFaceEndpoint() {
2912
+ return (await this.options.globalSettingsManager.readSettings()).translationEngines.local.hfEndpoint;
2913
+ }
2914
+ isActiveSession(modelId, sessionId) {
2915
+ return this.sessions.get(modelId)?.sessionId === sessionId;
2916
+ }
2917
+ emitLog(log) {
2918
+ this.logs.set(log.modelId, log);
2919
+ for (const listener of this.listeners) listener(log);
2920
+ }
2921
+ async getTransformersModule() {
2922
+ if (!this.transformersModulePromise) this.transformersModulePromise = this.loadTransformersModule();
2923
+ return this.transformersModulePromise;
2924
+ }
2925
+ async loadTransformersModule() {
2926
+ return import("@huggingface/transformers");
2927
+ }
2928
+ };
2929
+ function buildDownloadStateFiles(input) {
2930
+ const currentFileByPath = new Map(input.currentFiles.map((file) => [file.path, file]));
2931
+ return input.planFiles.map((file) => {
2932
+ const downloadedBytes = currentFileByPath.get(file.path)?.downloadedBytes;
2933
+ return {
2934
+ path: file.path,
2935
+ sizeBytes: file.sizeBytes,
2936
+ downloadedBytes: downloadedBytes === void 0 ? 0 : file.sizeBytes === void 0 ? downloadedBytes : Math.min(downloadedBytes, file.sizeBytes)
2937
+ };
2938
+ });
2939
+ }
2940
+ function sumDownloadedBytes(files) {
2941
+ return files.reduce((total, file) => {
2942
+ const downloadedBytes = file.downloadedBytes ?? 0;
2943
+ if (file.sizeBytes === void 0) return total + downloadedBytes;
2944
+ return total + Math.min(downloadedBytes, file.sizeBytes);
2945
+ }, 0);
2946
+ }
2947
+ function createAbortableFetch(signal) {
2948
+ return (input, init) => fetch(input, {
2949
+ ...init,
2950
+ signal: mergeAbortSignals(init?.signal, signal)
2951
+ });
2952
+ }
2953
+ function mergeAbortSignals(left, right) {
2954
+ if (!left) return right;
2955
+ if (left === right) return right;
2956
+ const controller = new AbortController();
2957
+ const abort = () => controller.abort();
2958
+ left.addEventListener("abort", abort, { once: true });
2959
+ right.addEventListener("abort", abort, { once: true });
2960
+ if (left.aborted || right.aborted) controller.abort();
2961
+ return controller.signal;
2962
+ }
2963
+ function throwIfAborted(signal) {
2964
+ if (signal.aborted) throw new Error("Local model download aborted.");
2965
+ }
2966
+ async function downloadHuggingFaceFileToCacheDirWithProgress(input) {
2967
+ const revision = "main";
2968
+ let lastError;
2969
+ const info = await readHuggingFaceFileDownloadInfoWithRetry({
2970
+ repo: input.repo,
2971
+ path: input.path,
2972
+ revision,
2973
+ hubUrl: input.hubUrl,
2974
+ retryPolicy: input.retryPolicy,
2975
+ fetch: input.fetch,
2976
+ signal: input.signal,
2977
+ onRetry: input.onRetry
2978
+ });
2979
+ if (!info) throw new Error(`Cannot get path info for ${input.path}.`);
2980
+ const totalBytes = input.expectedSizeBytes ?? info.size;
2981
+ if (totalBytes === void 0) throw new Error(`Cannot get path info for ${input.path}.`);
2982
+ const cachePaths = getHubCacheFilePaths({
2983
+ cacheDir: input.cacheDir,
2984
+ modelId: input.repo.name,
2985
+ filePath: input.path,
2986
+ revision,
2987
+ etag: info.etag
2988
+ });
2989
+ const existingPointerSize = await readPathSize(cachePaths.pointerPath);
2990
+ if (existingPointerSize !== null && existingPointerSize >= totalBytes) {
2991
+ await input.onProgress(totalBytes);
2992
+ return cachePaths.pointerPath;
2993
+ }
2994
+ for (let attempt = 0; attempt <= input.retryPolicy.limit; attempt += 1) try {
2995
+ throwIfAborted(input.signal);
2996
+ let resumeBytes = await readPathSize(cachePaths.incompletePath);
2997
+ if (resumeBytes !== null && resumeBytes > totalBytes) {
2998
+ await rm(cachePaths.incompletePath, { force: true });
2999
+ resumeBytes = 0;
3000
+ }
3001
+ if (resumeBytes !== null && resumeBytes > 0) await input.onProgress(Math.min(resumeBytes, totalBytes));
3002
+ if (!await streamDownloadToIncompleteFile({
3003
+ url: info.url,
3004
+ incompletePath: cachePaths.incompletePath,
3005
+ startBytes: resumeBytes ?? 0,
3006
+ totalBytes,
3007
+ accessToken: void 0,
3008
+ fetch: input.fetch,
3009
+ signal: input.signal,
3010
+ onProgress: input.onProgress
3011
+ })) {
3012
+ const blob = await downloadFile({
3013
+ repo: input.repo,
3014
+ path: input.path,
3015
+ revision,
3016
+ hubUrl: input.hubUrl,
3017
+ fetch: input.fetch,
3018
+ downloadInfo: info,
3019
+ xet: false
3020
+ });
3021
+ if (!blob) throw new Error(`Invalid response for file ${input.path}.`);
3022
+ await appendBlobToIncompleteFile({
3023
+ blob: resumeBytes && resumeBytes > 0 ? blob.slice(resumeBytes, totalBytes) : blob,
3024
+ incompletePath: cachePaths.incompletePath,
3025
+ startBytes: resumeBytes ?? 0,
3026
+ totalBytes,
3027
+ onProgress: input.onProgress
3028
+ });
3029
+ }
3030
+ await finalizeHubCacheFile(cachePaths);
3031
+ await input.onProgress(totalBytes);
3032
+ return cachePaths.pointerPath;
3033
+ } catch (error) {
3034
+ lastError = error;
3035
+ if (!isRetryableDownloadError(error) || attempt === input.retryPolicy.limit) throw error;
3036
+ const retryDelayMs = Math.min(input.retryPolicy.maxDelayMs, input.retryPolicy.delayMs * (attempt + 1));
3037
+ await input.onRetry?.({
3038
+ retryDelayMs,
3039
+ phase: "download"
3040
+ });
3041
+ await delay(retryDelayMs, input.signal);
3042
+ }
3043
+ throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error(`Cannot download ${input.path}.`);
3044
+ }
3045
+ async function readHuggingFaceFileDownloadInfoWithRetry(input) {
3046
+ let lastError;
3047
+ for (let attempt = 0; attempt <= input.retryPolicy.limit; attempt += 1) try {
3048
+ throwIfAborted(input.signal);
3049
+ return await fileDownloadInfo({
3050
+ repo: input.repo,
3051
+ path: input.path,
3052
+ revision: input.revision,
3053
+ hubUrl: input.hubUrl,
3054
+ fetch: input.fetch
3055
+ });
3056
+ } catch (error) {
3057
+ lastError = error;
3058
+ if (!isRetryableDownloadError(error) || attempt === input.retryPolicy.limit) throw error;
3059
+ const retryDelayMs = Math.min(input.retryPolicy.maxDelayMs, input.retryPolicy.delayMs * (attempt + 1));
3060
+ await input.onRetry?.({
3061
+ retryDelayMs,
3062
+ phase: "metadata"
3063
+ });
3064
+ await delay(retryDelayMs, input.signal);
3065
+ }
3066
+ throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error(`Cannot get path info for ${input.path}.`);
3067
+ }
3068
+ async function appendBlobToIncompleteFile(input) {
3069
+ await mkdir(dirname(input.incompletePath), { recursive: true });
3070
+ const fileHandle = await open(input.incompletePath, "a");
3071
+ const reader = input.blob.stream().getReader();
3072
+ let downloadedBytes = input.startBytes;
3073
+ try {
3074
+ while (true) {
3075
+ const result = await reader.read();
3076
+ if (result.done) break;
3077
+ await fileHandle.write(result.value);
3078
+ downloadedBytes += result.value.byteLength;
3079
+ await input.onProgress(Math.min(downloadedBytes, input.totalBytes));
3080
+ }
3081
+ } finally {
3082
+ await reader.cancel().catch(() => void 0);
3083
+ await fileHandle.close();
3084
+ }
3085
+ }
3086
+ async function streamDownloadToIncompleteFile(input) {
3087
+ const headers = new Headers();
3088
+ if (input.accessToken) headers.set("Authorization", `Bearer ${input.accessToken}`);
3089
+ if (input.startBytes > 0) headers.set("Range", `bytes=${input.startBytes}-`);
3090
+ const response = await input.fetch(input.url, {
3091
+ method: "GET",
3092
+ headers,
3093
+ signal: input.signal
3094
+ });
3095
+ if (!response.ok && response.status !== 206) throw new Error(`Invalid response for file download: status ${response.status}.`);
3096
+ if (!response.body) return false;
3097
+ await mkdir(dirname(input.incompletePath), { recursive: true });
3098
+ const fileHandle = await open(input.incompletePath, input.startBytes > 0 ? "a" : "w");
3099
+ const reader = response.body.getReader();
3100
+ let downloadedBytes = input.startBytes;
3101
+ try {
3102
+ while (true) {
3103
+ const result = await reader.read();
3104
+ if (result.done) break;
3105
+ await fileHandle.write(result.value);
3106
+ downloadedBytes += result.value.byteLength;
3107
+ await input.onProgress(Math.min(downloadedBytes, input.totalBytes));
3108
+ }
3109
+ } finally {
3110
+ await reader.cancel().catch(() => void 0);
3111
+ await fileHandle.close();
3112
+ }
3113
+ return true;
3114
+ }
3115
+ async function finalizeHubCacheFile(input) {
3116
+ await mkdir(dirname(input.blobPath), { recursive: true });
3117
+ await mkdir(dirname(input.pointerPath), { recursive: true });
3118
+ await rm(input.blobPath, { force: true });
3119
+ await rename(input.incompletePath, input.blobPath);
3120
+ await unlink(input.pointerPath).catch(() => void 0);
3121
+ await symlink(input.blobPath, input.pointerPath);
3122
+ }
3123
+ function getHubCacheFilePaths(input) {
3124
+ const repoPath = getHubCacheRepoPath(input.cacheDir, input.modelId);
3125
+ const snapshotId = sanitizeEtag(input.etag) || sanitizeId(input.revision);
3126
+ const blobPath = join(repoPath, "blobs", sanitizeEtag(input.etag));
3127
+ return {
3128
+ blobPath,
3129
+ incompletePath: `${blobPath}.incomplete`,
3130
+ pointerPath: join(repoPath, "snapshots", snapshotId, input.filePath)
3131
+ };
3132
+ }
3133
+ async function readPathSize(path) {
3134
+ try {
3135
+ return (await stat(path)).size;
3136
+ } catch {
3137
+ return null;
3138
+ }
3139
+ }
3140
+ function isRetryableDownloadError(error) {
3141
+ return isRetryableNetworkError(error, { treatUnknownAsRetryable: true });
3142
+ }
3143
+ function delay(ms, signal) {
3144
+ return new Promise((resolve$1, reject) => {
3145
+ const timer = setTimeout(() => {
3146
+ signal?.removeEventListener("abort", onAbort);
3147
+ resolve$1();
3148
+ }, ms);
3149
+ const onAbort = () => {
3150
+ clearTimeout(timer);
3151
+ signal?.removeEventListener("abort", onAbort);
3152
+ reject(/* @__PURE__ */ new Error("Local model download aborted."));
3153
+ };
3154
+ if (signal) {
3155
+ if (signal.aborted) {
3156
+ clearTimeout(timer);
3157
+ reject(/* @__PURE__ */ new Error("Local model download aborted."));
3158
+ return;
3159
+ }
3160
+ signal.addEventListener("abort", onAbort, { once: true });
3161
+ }
3162
+ });
3163
+ }
3164
+ function formatDuration(ms) {
3165
+ if (ms < 1e3) return `${ms} ms`;
3166
+ const seconds = ms / 1e3;
3167
+ return seconds >= 10 ? `${Math.round(seconds)} s` : `${seconds.toFixed(1)} s`;
3168
+ }
3169
+ async function mirrorHubCacheFileForTransformers(input) {
3170
+ const sourcePath = await resolveRealCacheFile(input.cachedPath);
3171
+ await copyFileIfMissing(sourcePath, join(getTransformersLocalModelPath(input.cacheDir, input.modelId), input.filePath));
3172
+ await copyFileIfMissing(sourcePath, join(getTransformersFileCacheModelPath(input.cacheDir, input.modelId), input.filePath));
3173
+ }
3174
+ async function copyFileIfMissing(sourcePath, targetPath) {
3175
+ if (existsSync(targetPath)) return;
3176
+ await mkdir(dirname(targetPath), { recursive: true });
3177
+ await copyFile(sourcePath, targetPath);
3178
+ }
3179
+ async function readSymlinkTarget(path) {
3180
+ return readlink(path);
3181
+ }
3182
+ async function resolveRealCacheFile(path) {
3183
+ if (!(await lstat(path)).isSymbolicLink()) return path;
3184
+ return resolve(dirname(path), await readSymlinkTarget(path));
3185
+ }
3186
+ function getHubCacheRepoPath(cacheDir, modelId) {
3187
+ return join(cacheDir, `models--${modelId.split("/").join("--")}`);
3188
+ }
3189
+ function toCatalogItem(candidate, asset) {
3190
+ const hasSelectableGroup = candidate.downloadGroups?.some((group) => group.selectable) ?? false;
3191
+ return {
3192
+ ...candidate,
3193
+ asset,
3194
+ selectable: hasSelectableGroup || (candidate.size.estimatedTotalBytes ?? 0) > 0,
3195
+ local: asset.status === "downloaded" || asset.status === "paused" || asset.status === "downloading" || (asset.progress ?? 0) > 0
3196
+ };
3197
+ }
3198
+ function compareCatalogItems(left, right) {
3199
+ if (left.local !== right.local) return left.local ? -1 : 1;
3200
+ if (left.asset.selected !== right.asset.selected) return left.asset.selected ? -1 : 1;
3201
+ const rightProgress = right.asset.progress ?? 0;
3202
+ const leftProgress = left.asset.progress ?? 0;
3203
+ if (left.local && right.local && leftProgress !== rightProgress) return rightProgress - leftProgress;
3204
+ return right.downloads - left.downloads;
3205
+ }
3206
+ function sanitizeId(value) {
3207
+ return value.replace(/[^a-zA-Z0-9_-]+/g, "-");
3208
+ }
3209
+ function sanitizeEtag(value) {
3210
+ return sanitizeId(value.replace(/^W\//, "").replace(/^"+|"+$/g, ""));
3211
+ }
3212
+ function formatBytes(value) {
3213
+ if (!Number.isFinite(value) || value <= 0) return "0 B";
3214
+ const units = [
3215
+ "B",
3216
+ "KB",
3217
+ "MB",
3218
+ "GB"
3219
+ ];
3220
+ let size = value;
3221
+ let unitIndex = 0;
3222
+ while (size >= 1024 && unitIndex < units.length - 1) {
3223
+ size /= 1024;
3224
+ unitIndex += 1;
3225
+ }
3226
+ const digits = size >= 100 || unitIndex === 0 ? 0 : 1;
3227
+ return `${size.toFixed(digits)} ${units[unitIndex]}`;
3228
+ }
3229
+
1451
3230
  //#endregion
1452
3231
  //#region src/notification-service.ts
1453
3232
  var NotificationService = class {
@@ -3085,7 +4864,13 @@ const globalSettingsRouter = router({
3085
4864
  get: publicProcedure.query(({ ctx }) => {
3086
4865
  return ctx.globalSettingsManager.readSettings();
3087
4866
  }),
3088
- update: publicProcedure.input(OpenSpecUIGlobalSettingsSchema.partial().extend({ translationCache: TranslationCacheSettingsSchema.partial().optional() })).mutation(async ({ ctx, input }) => {
4867
+ update: publicProcedure.input(OpenSpecUIGlobalSettingsSchema.partial().extend({
4868
+ translationCache: TranslationCacheSettingsSchema.partial().optional(),
4869
+ translationEngines: z.object({
4870
+ openai: TranslationOpenAISettingsSchema.partial().optional(),
4871
+ local: TranslationLocalSettingsSchema.partial().optional()
4872
+ }).optional()
4873
+ })).mutation(async ({ ctx, input }) => {
3089
4874
  await ctx.globalSettingsManager.writeSettings(input);
3090
4875
  return { success: true };
3091
4876
  }),
@@ -3110,6 +4895,96 @@ const translationCacheRouter = router({
3110
4895
  return ctx.translationCacheService.clear();
3111
4896
  })
3112
4897
  });
4898
+ const translationEnginesRouter = router({
4899
+ list: publicProcedure.query(({ ctx }) => {
4900
+ return ctx.translationEngineService.listEngines();
4901
+ }),
4902
+ searchModels: publicProcedure.input(z.object({
4903
+ engineId: ServiceTranslationEngineIdSchema,
4904
+ query: z.string().optional(),
4905
+ sourceLanguage: z.string().optional(),
4906
+ targetLanguage: z.string().optional(),
4907
+ limit: z.number().int().positive().max(20).optional(),
4908
+ cursor: z.string().optional()
4909
+ })).query(({ ctx, input }) => {
4910
+ return ctx.translationEngineService.searchModels(input);
4911
+ }),
4912
+ getModelDownloadPlan: publicProcedure.input(z.object({
4913
+ engineId: ServiceTranslationEngineIdSchema,
4914
+ model: z.string().min(1),
4915
+ selectedGroupId: z.string().min(1).optional()
4916
+ })).query(({ ctx, input }) => {
4917
+ return ctx.translationEngineService.getModelDownloadPlan(input);
4918
+ }),
4919
+ select: publicProcedure.input(z.object({ engineId: TranslationEngineIdSchema })).mutation(({ ctx, input }) => {
4920
+ return ctx.translationEngineService.selectEngine(input.engineId);
4921
+ }),
4922
+ batchTranslate: publicProcedure.input(BatchTranslateInputSchema).subscription(({ ctx, input }) => {
4923
+ return ctx.translationEngineService.batchTranslate(input);
4924
+ })
4925
+ });
4926
+ const localModelsRouter = router({
4927
+ listLocal: publicProcedure.query(({ ctx }) => {
4928
+ return ctx.localModelAssetService.listLocalCatalog();
4929
+ }),
4930
+ searchRemote: publicProcedure.input(z.object({
4931
+ requestId: z.string().min(1).optional(),
4932
+ query: z.string().optional(),
4933
+ sourceLanguage: z.string().optional(),
4934
+ targetLanguage: z.string().optional(),
4935
+ limit: z.number().int().positive().max(20).optional(),
4936
+ cursor: z.string().optional()
4937
+ })).query(({ ctx, input }) => {
4938
+ return ctx.localModelAssetService.searchRemoteCatalog({
4939
+ engineId: "local",
4940
+ ...input
4941
+ });
4942
+ }),
4943
+ searchRemoteStream: publicProcedure.input(z.object({
4944
+ requestId: z.string().min(1),
4945
+ query: z.string().optional(),
4946
+ sourceLanguage: z.string().optional(),
4947
+ targetLanguage: z.string().optional(),
4948
+ limit: z.number().int().positive().max(20).optional(),
4949
+ cursor: z.string().optional()
4950
+ })).subscription(({ ctx, input }) => {
4951
+ return ctx.localModelAssetService.subscribeRemoteCatalog({
4952
+ engineId: "local",
4953
+ ...input
4954
+ });
4955
+ }),
4956
+ state: publicProcedure.input(z.object({
4957
+ modelId: z.string().min(1),
4958
+ selectedGroupId: z.string().min(1).optional()
4959
+ })).query(({ ctx, input }) => {
4960
+ return ctx.localModelAssetService.readSelectedModelState(input.modelId, input.selectedGroupId);
4961
+ }),
4962
+ subscribeLogs: publicProcedure.subscription(({ ctx }) => {
4963
+ return ctx.localModelAssetService.subscribeLogs();
4964
+ }),
4965
+ markSelected: publicProcedure.input(z.object({ modelId: z.string().min(1) })).mutation(async ({ ctx, input }) => {
4966
+ await ctx.localModelAssetService.markSelectedModel(input.modelId);
4967
+ return { success: true };
4968
+ }),
4969
+ download: publicProcedure.input(z.object({
4970
+ modelId: z.string().min(1),
4971
+ selectedGroupId: z.string().min(1).optional()
4972
+ })).mutation(async ({ ctx, input }) => {
4973
+ return ctx.localModelAssetService.startDownload(input.modelId, input.selectedGroupId);
4974
+ }),
4975
+ pause: publicProcedure.input(z.object({ modelId: z.string().min(1) })).mutation(({ ctx, input }) => {
4976
+ return ctx.localModelAssetService.pauseDownload(input.modelId);
4977
+ }),
4978
+ resume: publicProcedure.input(z.object({
4979
+ modelId: z.string().min(1),
4980
+ selectedGroupId: z.string().min(1).optional()
4981
+ })).mutation(async ({ ctx, input }) => {
4982
+ return ctx.localModelAssetService.resumeDownload(input.modelId, input.selectedGroupId);
4983
+ }),
4984
+ delete: publicProcedure.input(z.object({ modelId: z.string().min(1) })).mutation(({ ctx, input }) => {
4985
+ return ctx.localModelAssetService.deleteModel(input.modelId);
4986
+ })
4987
+ });
3113
4988
  const OPSX_CORE_PROFILE_WORKFLOWS = [
3114
4989
  "propose",
3115
4990
  "explore",
@@ -3552,7 +5427,13 @@ const configRouter = router({
3552
5427
  dashboard: DashboardConfigSchema.partial().optional(),
3553
5428
  git: GitConfigSchema.partial().optional(),
3554
5429
  notifications: NotificationSettingsSchema.partial().optional(),
3555
- translation: DocumentTranslationConfigSchema.partial().optional()
5430
+ translation: DocumentTranslationConfigSchema.partial().extend({ engines: z.object({
5431
+ local: z.object({
5432
+ model: z.string().min(1).optional(),
5433
+ selectedGroupId: z.string().min(1).optional()
5434
+ }).optional(),
5435
+ openai: z.object({ model: z.string().min(1).optional() }).optional()
5436
+ }).optional() }).optional()
3556
5437
  })).mutation(async ({ ctx, input }) => {
3557
5438
  const hasCliCommand = input.cli !== void 0 && Object.prototype.hasOwnProperty.call(input.cli, "command");
3558
5439
  const hasCliArgs = input.cli !== void 0 && Object.prototype.hasOwnProperty.call(input.cli, "args");
@@ -4207,6 +6088,8 @@ const appRouter = router({
4207
6088
  config: configRouter,
4208
6089
  globalSettings: globalSettingsRouter,
4209
6090
  translationCache: translationCacheRouter,
6091
+ translationEngines: translationEnginesRouter,
6092
+ localModels: localModelsRouter,
4210
6093
  notifications: notificationsRouter,
4211
6094
  sounds: soundsRouter,
4212
6095
  cli: cliRouter,
@@ -4371,6 +6254,10 @@ var SqliteTranslationCacheAdapter = class {
4371
6254
  placeholder_topology_hash TEXT NOT NULL,
4372
6255
  attribute_topology_hash TEXT NOT NULL,
4373
6256
  display_policy_version INTEGER NOT NULL,
6257
+ engine_id TEXT NOT NULL DEFAULT 'browser',
6258
+ engine_version TEXT,
6259
+ model TEXT,
6260
+ translator_contract_version INTEGER NOT NULL DEFAULT 1,
4374
6261
  created_at INTEGER NOT NULL,
4375
6262
  last_accessed_at INTEGER NOT NULL
4376
6263
  );
@@ -4378,13 +6265,18 @@ var SqliteTranslationCacheAdapter = class {
4378
6265
  ON translation_cache_entries(last_accessed_at ASC);
4379
6266
  `);
4380
6267
  ensureTargetNodesJsonColumn(database);
6268
+ ensureColumn(database, "engine_id", "TEXT NOT NULL DEFAULT 'browser'");
6269
+ ensureColumn(database, "engine_version", "TEXT");
6270
+ ensureColumn(database, "model", "TEXT");
6271
+ ensureColumn(database, "translator_contract_version", "INTEGER NOT NULL DEFAULT 1");
4381
6272
  this.database = database;
4382
6273
  }
4383
6274
  async read(keyHash, now) {
4384
6275
  const database = await this.requireDatabase();
4385
6276
  const row = database.prepare(`SELECT key_hash, cache_key, source_text, translated_text, target_nodes_json, source_language,
4386
6277
  target_language, placeholder_topology_hash, attribute_topology_hash,
4387
- display_policy_version, created_at, last_accessed_at
6278
+ display_policy_version, engine_id, engine_version, model, translator_contract_version,
6279
+ created_at, last_accessed_at
4388
6280
  FROM translation_cache_entries
4389
6281
  WHERE key_hash = ?`).get(keyHash);
4390
6282
  if (!isSqliteTranslationCacheRow(row)) return null;
@@ -4400,16 +6292,23 @@ var SqliteTranslationCacheAdapter = class {
4400
6292
  placeholderTopologyHash: row.placeholder_topology_hash,
4401
6293
  attributeTopologyHash: row.attribute_topology_hash,
4402
6294
  displayPolicyVersion: row.display_policy_version,
6295
+ engineId: row.engine_id,
6296
+ ...row.engine_version ? { engineVersion: row.engine_version } : {},
6297
+ ...row.model ? { model: row.model } : {},
6298
+ translatorContractVersion: row.translator_contract_version,
4403
6299
  createdAt: row.created_at,
4404
6300
  lastAccessedAt: now
4405
6301
  };
4406
6302
  }
4407
6303
  async write(input, now) {
4408
- (await this.requireDatabase()).prepare(`INSERT INTO translation_cache_entries (
6304
+ const database = await this.requireDatabase();
6305
+ const entry = TranslationCacheWriteInputSchema.parse(input);
6306
+ database.prepare(`INSERT INTO translation_cache_entries (
4409
6307
  key_hash, cache_key, source_text, translated_text, target_nodes_json, source_language,
4410
6308
  target_language, placeholder_topology_hash, attribute_topology_hash,
4411
- display_policy_version, created_at, last_accessed_at
4412
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
6309
+ display_policy_version, engine_id, engine_version, model, translator_contract_version,
6310
+ created_at, last_accessed_at
6311
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4413
6312
  ON CONFLICT(key_hash) DO UPDATE SET
4414
6313
  cache_key = excluded.cache_key,
4415
6314
  source_text = excluded.source_text,
@@ -4420,7 +6319,11 @@ var SqliteTranslationCacheAdapter = class {
4420
6319
  placeholder_topology_hash = excluded.placeholder_topology_hash,
4421
6320
  attribute_topology_hash = excluded.attribute_topology_hash,
4422
6321
  display_policy_version = excluded.display_policy_version,
4423
- last_accessed_at = excluded.last_accessed_at`).run(input.keyHash, input.key, input.sourceText, input.translatedText, input.targetNodesJson ?? null, input.sourceLanguage, input.targetLanguage, input.placeholderTopologyHash, input.attributeTopologyHash, input.displayPolicyVersion, now, now);
6322
+ engine_id = excluded.engine_id,
6323
+ engine_version = excluded.engine_version,
6324
+ model = excluded.model,
6325
+ translator_contract_version = excluded.translator_contract_version,
6326
+ last_accessed_at = excluded.last_accessed_at`).run(entry.keyHash, entry.key, entry.sourceText, entry.translatedText, entry.targetNodesJson ?? null, entry.sourceLanguage, entry.targetLanguage, entry.placeholderTopologyHash, entry.attributeTopologyHash, entry.displayPolicyVersion, entry.engineId, entry.engineVersion ?? null, entry.model ?? null, entry.translatorContractVersion, now, now);
4424
6327
  }
4425
6328
  async count() {
4426
6329
  return readSqliteCount((await this.requireDatabase()).prepare("SELECT COUNT(*) AS count FROM translation_cache_entries").get());
@@ -4480,15 +6383,18 @@ function isBunRuntime() {
4480
6383
  return typeof process.versions.bun === "string";
4481
6384
  }
4482
6385
  function ensureTargetNodesJsonColumn(database) {
6386
+ ensureColumn(database, "target_nodes_json", "TEXT");
6387
+ }
6388
+ function ensureColumn(database, columnName, definition) {
4483
6389
  if (!database.prepare("PRAGMA table_info(translation_cache_entries)").all().some((row) => {
4484
6390
  if (!row || typeof row !== "object") return false;
4485
- return row.name === "target_nodes_json";
4486
- })) database.exec("ALTER TABLE translation_cache_entries ADD COLUMN target_nodes_json TEXT");
6391
+ return row.name === columnName;
6392
+ })) database.exec(`ALTER TABLE translation_cache_entries ADD COLUMN ${columnName} ${definition}`);
4487
6393
  }
4488
6394
  function isSqliteTranslationCacheRow(value) {
4489
6395
  if (!value || typeof value !== "object") return false;
4490
6396
  const row = value;
4491
- return typeof row.key_hash === "string" && typeof row.cache_key === "string" && typeof row.source_text === "string" && typeof row.translated_text === "string" && (typeof row.target_nodes_json === "string" || row.target_nodes_json === null) && typeof row.source_language === "string" && typeof row.target_language === "string" && typeof row.placeholder_topology_hash === "string" && typeof row.attribute_topology_hash === "string" && typeof row.display_policy_version === "number" && typeof row.created_at === "number" && typeof row.last_accessed_at === "number";
6397
+ return typeof row.key_hash === "string" && typeof row.cache_key === "string" && typeof row.source_text === "string" && typeof row.translated_text === "string" && (typeof row.target_nodes_json === "string" || row.target_nodes_json === null) && typeof row.source_language === "string" && typeof row.target_language === "string" && typeof row.placeholder_topology_hash === "string" && typeof row.attribute_topology_hash === "string" && typeof row.display_policy_version === "number" && (row.engine_id === "browser" || row.engine_id === "local" || row.engine_id === "openai") && (typeof row.engine_version === "string" || row.engine_version === null) && (typeof row.model === "string" || row.model === null) && typeof row.translator_contract_version === "number" && typeof row.created_at === "number" && typeof row.last_accessed_at === "number";
4492
6398
  }
4493
6399
  function readSqliteCount(value) {
4494
6400
  if (!value || typeof value !== "object") return 0;
@@ -4496,18 +6402,6 @@ function readSqliteCount(value) {
4496
6402
  return typeof count === "number" ? count : 0;
4497
6403
  }
4498
6404
 
4499
- //#endregion
4500
- //#region src/translation-cache-path.ts
4501
- function getDefaultTranslationCacheDatabasePath() {
4502
- return join(getOpenSpecUICacheDir(), "translation-cache.sqlite");
4503
- }
4504
- function getOpenSpecUICacheDir() {
4505
- const currentPlatform = platform();
4506
- if (currentPlatform === "darwin") return join(homedir(), "Library", "Caches", "openspecui");
4507
- if (currentPlatform === "win32") return join(process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local"), "OpenSpecUI", "Cache");
4508
- return join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "openspecui");
4509
- }
4510
-
4511
6405
  //#endregion
4512
6406
  //#region src/translation-cache-service.ts
4513
6407
  var TranslationCacheService = class {
@@ -4568,6 +6462,225 @@ var TranslationCacheService = class {
4568
6462
  }
4569
6463
  };
4570
6464
 
6465
+ //#endregion
6466
+ //#region src/translation-engine-service.ts
6467
+ var TranslationEngineService = class {
6468
+ projectDir;
6469
+ configManager;
6470
+ globalSettingsManager;
6471
+ now;
6472
+ localCacheDir;
6473
+ localAssetStore;
6474
+ localFetchCacheStore;
6475
+ constructor(options) {
6476
+ ensureProxyAwareFetchDispatcher();
6477
+ this.projectDir = options.projectDir;
6478
+ this.configManager = options.configManager;
6479
+ this.globalSettingsManager = options.globalSettingsManager;
6480
+ this.now = options.now ?? Date.now;
6481
+ this.localCacheDir = options.localCacheDir ?? getDefaultLocalModelCacheDir();
6482
+ this.localAssetStore = new LocalModelAssetStore({ indexPath: options.localAssetIndexPath ?? getDefaultLocalModelIndexPath() });
6483
+ this.localFetchCacheStore = new LocalModelFetchCacheStore({
6484
+ cachePath: options.localFetchCachePath ?? getDefaultLocalModelFetchCachePath(),
6485
+ now: this.now
6486
+ });
6487
+ }
6488
+ async listEngines() {
6489
+ const [config, globalSettings] = await Promise.all([this.configManager.readConfig(), this.globalSettingsManager.readSettings()]);
6490
+ return TRANSLATION_ENGINE_MANIFESTS.map((manifest) => ({
6491
+ ...manifest,
6492
+ selected: config.translation.engineId === manifest.id,
6493
+ status: "available",
6494
+ model: manifest.id === "local" ? config.translation.engines.local.model ?? globalSettings.translationEngines.local.model : manifest.id === "openai" ? config.translation.engines.openai.model ?? globalSettings.translationEngines.openai.model : void 0
6495
+ }));
6496
+ }
6497
+ async searchModels(input) {
6498
+ if (input.engineId !== "local") return { items: [] };
6499
+ return searchLocalModels(input, { hfEndpoint: (await this.globalSettingsManager.readSettings()).translationEngines.local.hfEndpoint });
6500
+ }
6501
+ async getModelDownloadPlan(input) {
6502
+ if (input.engineId !== "local") return null;
6503
+ const state = (await this.localAssetStore.readMap()).get(input.model);
6504
+ const plan = await resolveLocalModelRuntimePlanFromProject({
6505
+ projectDir: this.projectDir,
6506
+ globalSettingsManager: this.globalSettingsManager,
6507
+ modelId: input.model,
6508
+ selectedGroupId: input.selectedGroupId,
6509
+ cacheDir: this.localCacheDir,
6510
+ fetchCacheStore: this.localFetchCacheStore,
6511
+ loadTransformersModule: this.loadLocalTransformersModuleForPlan.bind(this)
6512
+ }).catch(() => null);
6513
+ const fallbackPlan = selectPersistedLocalPlan(state, input.selectedGroupId);
6514
+ const effectivePlan = plan ?? fallbackPlan;
6515
+ if (!effectivePlan) return null;
6516
+ return enrichDownloadPlanWithAssetSnapshot(effectivePlan, state, input.selectedGroupId);
6517
+ }
6518
+ async selectEngine(engineId) {
6519
+ await this.configManager.writeConfig({ translation: { engineId } });
6520
+ return { success: true };
6521
+ }
6522
+ batchTranslate(input) {
6523
+ return observable((emit) => {
6524
+ if (input.engineId === "browser") {
6525
+ emit.error(/* @__PURE__ */ new Error("Browser translator runs in the browser runtime."));
6526
+ return () => {};
6527
+ }
6528
+ const controller = new AbortController();
6529
+ (async () => {
6530
+ try {
6531
+ if (input.engineId === "browser") throw new Error("Browser translator runs in the browser runtime.");
6532
+ const dtype = await this.readLocalDtype(input.engineId, input.model, input.selectedGroupId);
6533
+ if (input.engineId === "local" && input.model) await this.assertLocalModelReady(input.model, input.selectedGroupId);
6534
+ const translator = await (await this.loadFactory(input.engineId, input.model)).create({
6535
+ sourceLanguage: input.sourceLanguage,
6536
+ targetLanguage: input.targetLanguage,
6537
+ model: input.model,
6538
+ dtype,
6539
+ runtimeConfig: input.engineId === "local" && input.model ? await this.readLocalRuntimeConfig(input.model) : void 0,
6540
+ signal: controller.signal
6541
+ });
6542
+ try {
6543
+ for await (const event of translator.batchTranslate(input.inputs, {
6544
+ instructions: input.instructions,
6545
+ context: input.context,
6546
+ signal: controller.signal
6547
+ })) emit.next(event);
6548
+ emit.complete();
6549
+ } finally {
6550
+ translator.destroy?.();
6551
+ }
6552
+ } catch (error) {
6553
+ if (!controller.signal.aborted) emit.error(error instanceof Error ? error : new Error(String(error)));
6554
+ }
6555
+ })();
6556
+ return () => {
6557
+ controller.abort();
6558
+ };
6559
+ });
6560
+ }
6561
+ async readLocalDtype(engineId, model, selectedGroupId) {
6562
+ if (engineId !== "local" || !model) return void 0;
6563
+ const effectiveSelectedGroupId = selectedGroupId ?? (await this.globalSettingsManager.readSettings()).translationEngines.local.selectedGroupId;
6564
+ if (!effectiveSelectedGroupId) return void 0;
6565
+ return (await this.getModelDownloadPlan({
6566
+ engineId: "local",
6567
+ model,
6568
+ selectedGroupId: effectiveSelectedGroupId
6569
+ }))?.groups?.find((group) => group.id === effectiveSelectedGroupId)?.dtype;
6570
+ }
6571
+ async assertLocalModelReady(model, selectedGroupId) {
6572
+ const plan = await this.getModelDownloadPlan({
6573
+ engineId: "local",
6574
+ model,
6575
+ selectedGroupId
6576
+ });
6577
+ const files = (plan?.groups?.find((group) => group.id === (selectedGroupId ?? plan.selectedGroupId)) ?? plan?.groups?.find((group) => group.selected))?.files ?? plan?.files ?? [];
6578
+ if (!plan || files.length === 0) throw new Error("No local runtime file plan is available for the selected model.");
6579
+ const cacheStatus = await readLocalModelFileStatus({
6580
+ cacheDir: this.localCacheDir,
6581
+ modelId: model,
6582
+ files: files.map((file) => file.path)
6583
+ });
6584
+ if (cacheStatus.allCached) {
6585
+ const current = (await this.localAssetStore.readMap()).get(model);
6586
+ if (current) await this.localAssetStore.upsert({
6587
+ ...current,
6588
+ status: "downloaded",
6589
+ progress: 1,
6590
+ bytesDownloaded: plan.estimatedTotalBytes ?? current.bytesDownloaded,
6591
+ totalBytes: plan.estimatedTotalBytes ?? current.totalBytes,
6592
+ resumable: false,
6593
+ error: void 0,
6594
+ plan,
6595
+ files: files.map((file) => ({
6596
+ path: file.path,
6597
+ sizeBytes: file.sizeBytes,
6598
+ downloadedBytes: file.sizeBytes
6599
+ })),
6600
+ installedAt: current.installedAt ?? this.now(),
6601
+ updatedAt: this.now()
6602
+ });
6603
+ return;
6604
+ }
6605
+ const allMissingFiles = cacheStatus.files.filter((file) => !file.cached).map((file) => file.file);
6606
+ const missingFiles = allMissingFiles.slice(0, 3);
6607
+ const suffix = allMissingFiles.length > missingFiles.length ? ` and ${allMissingFiles.length - missingFiles.length} more` : "";
6608
+ throw new Error(`Selected local model files are not installed locally: ${missingFiles.join(", ")}${suffix}.`);
6609
+ }
6610
+ async readLocalRuntimeConfig(model) {
6611
+ try {
6612
+ return JSON.parse(await readFile(join(this.localCacheDir, "models", model, "config.json"), "utf8"));
6613
+ } catch {
6614
+ return;
6615
+ }
6616
+ }
6617
+ async loadFactory(engineId, model) {
6618
+ const globalSettings = await this.globalSettingsManager.readSettings();
6619
+ if (engineId === "local") return (await import("@openspecui/local-translator")).createLocalTranslatorFactory({
6620
+ defaultModel: model ?? globalSettings.translationEngines.local.model,
6621
+ cacheDir: this.localCacheDir,
6622
+ localOnly: true
6623
+ });
6624
+ return (await import("@openspecui/openai-completion-translator")).createOpenAICompletionTranslatorFactory({
6625
+ baseUrl: globalSettings.translationEngines.openai.baseUrl,
6626
+ token: globalSettings.translationEngines.openai.token,
6627
+ model: model ?? globalSettings.translationEngines.openai.model
6628
+ });
6629
+ }
6630
+ async loadLocalTransformersModuleForPlan(_projectDir, _globalSettingsManager) {
6631
+ return await import("@huggingface/transformers");
6632
+ }
6633
+ };
6634
+ function enrichDownloadPlanWithAssetSnapshot(plan, state, selectedGroupId) {
6635
+ if (!state?.plan) return plan;
6636
+ const assetGroup = state.plan.groups?.find((group) => group.id === (selectedGroupId ?? plan.selectedGroupId));
6637
+ const mergedGroups = plan.groups?.map((group) => {
6638
+ const matchingAssetGroup = state.plan?.groups?.find((asset) => asset.id === group.id);
6639
+ if (!matchingAssetGroup) return group;
6640
+ return {
6641
+ ...group,
6642
+ estimatedTotalBytes: group.estimatedTotalBytes ?? matchingAssetGroup.estimatedTotalBytes,
6643
+ files: group.files.map((file) => {
6644
+ const matchingAssetFile = matchingAssetGroup.files.find((asset) => asset.path === file.path);
6645
+ return matchingAssetFile?.sizeBytes !== void 0 && file.sizeBytes === void 0 ? {
6646
+ ...file,
6647
+ sizeBytes: matchingAssetFile.sizeBytes
6648
+ } : file;
6649
+ })
6650
+ };
6651
+ });
6652
+ return {
6653
+ ...plan,
6654
+ estimatedTotalBytes: plan.estimatedTotalBytes ?? assetGroup?.estimatedTotalBytes ?? state.plan.estimatedTotalBytes,
6655
+ groups: mergedGroups
6656
+ };
6657
+ }
6658
+ function selectPersistedLocalPlan(state, selectedGroupId) {
6659
+ const plan = state?.plan;
6660
+ if (!plan) return null;
6661
+ if (!selectedGroupId || !plan.groups?.length) return {
6662
+ ...plan,
6663
+ files: [...plan.files],
6664
+ groups: plan.groups?.map((group) => ({
6665
+ ...group,
6666
+ files: [...group.files]
6667
+ }))
6668
+ };
6669
+ const selectedGroup = plan.groups.find((group) => group.id === selectedGroupId);
6670
+ if (!selectedGroup) return null;
6671
+ return {
6672
+ modelId: plan.modelId,
6673
+ estimatedTotalBytes: selectedGroup.estimatedTotalBytes,
6674
+ files: [...selectedGroup.files],
6675
+ selectedGroupId: selectedGroup.id,
6676
+ groups: plan.groups.map((group) => ({
6677
+ ...group,
6678
+ selected: group.id === selectedGroup.id,
6679
+ files: [...group.files]
6680
+ }))
6681
+ };
6682
+ }
6683
+
4571
6684
  //#endregion
4572
6685
  //#region src/workflow-invocation-service.ts
4573
6686
  const COMMAND_CAPABLE_ACTIONS = new Set([
@@ -4822,7 +6935,7 @@ function deferBackgroundTask(task) {
4822
6935
  function createServer(config) {
4823
6936
  const adapter = new OpenSpecAdapter(config.projectDir);
4824
6937
  const configManager = new ConfigManager(config.projectDir);
4825
- const globalSettingsManager = new GlobalSettingsManager();
6938
+ const globalSettingsManager = new GlobalSettingsManager(config.runtimePaths?.globalSettingsPath);
4826
6939
  const cliExecutor = new CliExecutor(configManager, config.projectDir);
4827
6940
  const kernel = config.kernel;
4828
6941
  const hookRuntime = createHookRuntime(config.projectDir);
@@ -4834,16 +6947,17 @@ function createServer(config) {
4834
6947
  });
4835
6948
  const notificationService = new NotificationService();
4836
6949
  const customSoundService = new CustomSoundService();
6950
+ const translationCacheDatabasePath = config.runtimePaths?.translationCacheDatabasePath ?? getDefaultTranslationCacheDatabasePath();
4837
6951
  let translationCacheAdapterPromise = null;
4838
6952
  const getTranslationCacheAdapter = () => {
4839
- translationCacheAdapterPromise ??= createRuntimeSqliteTranslationCacheAdapter(getDefaultTranslationCacheDatabasePath());
6953
+ translationCacheAdapterPromise ??= createRuntimeSqliteTranslationCacheAdapter(translationCacheDatabasePath);
4840
6954
  return translationCacheAdapterPromise;
4841
6955
  };
4842
6956
  const translationCacheService = new TranslationCacheService({
4843
6957
  configManager,
4844
6958
  globalSettingsManager,
4845
6959
  adapter: {
4846
- databasePath: getDefaultTranslationCacheDatabasePath(),
6960
+ databasePath: translationCacheDatabasePath,
4847
6961
  init: async () => (await getTranslationCacheAdapter()).init(),
4848
6962
  read: async (keyHash, now) => (await getTranslationCacheAdapter()).read(keyHash, now),
4849
6963
  write: async (input, now) => (await getTranslationCacheAdapter()).write(input, now),
@@ -4859,6 +6973,25 @@ function createServer(config) {
4859
6973
  console.warn("Translation cache write failed:", error);
4860
6974
  }
4861
6975
  });
6976
+ const nmtModelCacheDir = config.runtimePaths?.localModelCacheDir ?? getDefaultLocalModelCacheDir();
6977
+ const nmtModelIndexPath = config.runtimePaths?.localModelAssetIndexPath ?? getDefaultLocalModelIndexPath();
6978
+ const nmtModelFetchCachePath = config.runtimePaths?.localModelFetchCachePath ?? getDefaultLocalModelFetchCachePath();
6979
+ const translationEngineService = new TranslationEngineService({
6980
+ projectDir: config.projectDir,
6981
+ configManager,
6982
+ globalSettingsManager,
6983
+ localCacheDir: nmtModelCacheDir,
6984
+ localAssetIndexPath: nmtModelIndexPath,
6985
+ localFetchCachePath: nmtModelFetchCachePath
6986
+ });
6987
+ const localModelAssetService = new LocalModelAssetService({
6988
+ projectDir: config.projectDir,
6989
+ configManager,
6990
+ globalSettingsManager,
6991
+ cacheDir: nmtModelCacheDir,
6992
+ indexPath: nmtModelIndexPath,
6993
+ fetchCachePath: nmtModelFetchCachePath
6994
+ });
4862
6995
  const watcher = config.enableWatcher !== false ? new OpenSpecWatcher(config.projectDir) : void 0;
4863
6996
  const entityReadOptionsContext = {
4864
6997
  adapter,
@@ -4940,6 +7073,8 @@ function createServer(config) {
4940
7073
  customSoundService,
4941
7074
  globalSettingsManager,
4942
7075
  translationCacheService,
7076
+ translationEngineService,
7077
+ localModelAssetService,
4943
7078
  gitWorktreeHandoff: config.gitWorktreeHandoff,
4944
7079
  watcher,
4945
7080
  projectDir: config.projectDir
@@ -4960,6 +7095,8 @@ function createServer(config) {
4960
7095
  customSoundService,
4961
7096
  globalSettingsManager,
4962
7097
  translationCacheService,
7098
+ translationEngineService,
7099
+ localModelAssetService,
4963
7100
  gitWorktreeHandoff: config.gitWorktreeHandoff,
4964
7101
  watcher,
4965
7102
  projectDir: config.projectDir
@@ -4979,6 +7116,7 @@ function createServer(config) {
4979
7116
  customSoundService,
4980
7117
  globalSettingsManager,
4981
7118
  translationCacheService,
7119
+ translationEngineService,
4982
7120
  hookRuntime,
4983
7121
  watcher,
4984
7122
  createContext,
@@ -5077,6 +7215,7 @@ async function startServer(config, setupApp) {
5077
7215
  close: async () => {
5078
7216
  kernel.dispose();
5079
7217
  await server.hookRuntime.dispose();
7218
+ await server.createContext().localModelAssetService.close();
5080
7219
  server.translationCacheService.close();
5081
7220
  wsServer.close();
5082
7221
  httpServer.close();
@@ -5085,4 +7224,4 @@ async function startServer(config, setupApp) {
5085
7224
  }
5086
7225
 
5087
7226
  //#endregion
5088
- export { DocumentService, OPENSPECUI_HOOKS_RELATIVE_PATH, ProjectHookRuntime, WorkflowInvocationService, createHookRuntime, createServer, createWebSocketServer, findAvailablePort, isPortAvailable, startServer };
7227
+ export { DocumentService, LocalModelAssetService, OPENSPECUI_HOOKS_RELATIVE_PATH, ProjectHookRuntime, TranslationEngineService, WorkflowInvocationService, createHookRuntime, createServer, createWebSocketServer, findAvailablePort, isPortAvailable, startServer };