Compare commits
10 Commits
8ea88b67ec
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 60c8e935cf | |||
|
|
f19fde6879 | ||
|
|
599856cdb2 | ||
|
|
dcb044d8ea | ||
|
|
7f70e225f7 | ||
|
|
710f2f78a4 | ||
|
|
b680b7bfff | ||
|
|
d1669fb5b5 | ||
|
|
12dfaa9e7a | ||
|
|
b00230601b |
141
package-lock.json
generated
141
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "blogging-desktop-server",
|
"name": "blogging-desktop-server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "blogging-desktop-server",
|
"name": "blogging-desktop-server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^3.0.50",
|
"@ai-sdk/anthropic": "^3.0.50",
|
||||||
@@ -32,15 +32,15 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@picocss/pico": "^2.1.1",
|
"@picocss/pico": "^2.1.1",
|
||||||
"@xmldom/xmldom": "^0.8.11",
|
"@xmldom/xmldom": "^0.8.12",
|
||||||
"ai": "^6.0.105",
|
"ai": "^6.0.105",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"d3-cloud": "^1.2.8",
|
"d3-cloud": "^1.2.8",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.2",
|
||||||
"fast-xml-parser": "^5.5.6",
|
"fast-xml-parser": "^5.5.7",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"lightbox2": "^2.11.5",
|
"lightbox2": "^2.11.5",
|
||||||
"liquidjs": "^10.25.0",
|
"liquidjs": "^10.25.5",
|
||||||
"marked-react": "^3.0.2",
|
"marked-react": "^3.0.2",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"node-scp": "^0.0.25",
|
"node-scp": "^0.0.25",
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
"rsyncwrapper": "^3.1.0",
|
"rsyncwrapper": "^3.1.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"simple-git": "^3.32.3",
|
"simple-git": "^3.32.3",
|
||||||
"smol-toml": "^1.6.0",
|
"smol-toml": "^1.6.1",
|
||||||
"snowball-stemmers": "^0.6.0",
|
"snowball-stemmers": "^0.6.0",
|
||||||
"transliteration": "^2.6.1",
|
"transliteration": "^2.6.1",
|
||||||
"turndown": "^7.2.2",
|
"turndown": "^7.2.2",
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
"electron": "^40.4.0",
|
"electron": "^40.8.5",
|
||||||
"electron-builder": "^26.7.0",
|
"electron-builder": "^26.7.0",
|
||||||
"eslint": "^9.39.3",
|
"eslint": "^9.39.3",
|
||||||
"eslint-plugin-i18next": "^6.1.3",
|
"eslint-plugin-i18next": "^6.1.3",
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
"png-to-ico": "^3.0.1",
|
"png-to-ico": "^3.0.1",
|
||||||
"tsx": "^4.6.0",
|
"tsx": "^4.6.0",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.2",
|
||||||
"vitest": "^4.0.18",
|
"vitest": "^4.0.18",
|
||||||
"wait-on": "^9.0.3"
|
"wait-on": "^9.0.3"
|
||||||
}
|
}
|
||||||
@@ -2769,9 +2769,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@hono/node-server": {
|
"node_modules/@hono/node-server": {
|
||||||
"version": "1.19.10",
|
"version": "1.19.13",
|
||||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.10.tgz",
|
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz",
|
||||||
"integrity": "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==",
|
"integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.14.1"
|
"node": ">=18.14.1"
|
||||||
@@ -6148,9 +6148,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@xmldom/xmldom": {
|
"node_modules/@xmldom/xmldom": {
|
||||||
"version": "0.8.11",
|
"version": "0.8.12",
|
||||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
|
||||||
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
|
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
@@ -6719,15 +6719,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.5",
|
"version": "1.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
|
||||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.11",
|
"follow-redirects": "^1.15.11",
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bail": {
|
"node_modules/bail": {
|
||||||
@@ -8177,9 +8177,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/drizzle-orm": {
|
"node_modules/drizzle-orm": {
|
||||||
"version": "0.45.1",
|
"version": "0.45.2",
|
||||||
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz",
|
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz",
|
||||||
"integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==",
|
"integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@aws-sdk/client-rds-data": ">=3",
|
"@aws-sdk/client-rds-data": ">=3",
|
||||||
@@ -8345,9 +8345,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron": {
|
"node_modules/electron": {
|
||||||
"version": "40.4.0",
|
"version": "40.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/electron/-/electron-40.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/electron/-/electron-40.8.5.tgz",
|
||||||
"integrity": "sha512-31l4V7Ys4oUuXyaN/cCNnyBdDXN9RwOVOG+JhiHCf4zx5tZkHd43PKGY6KLEWpeYCxaphsuGSEjagJLfPqKj8g==",
|
"integrity": "sha512-pgTY/VPQKaiU4sTjfU96iyxCXrFm4htVPCMRT4b7q9ijNTRgtLmLvcmzp2G4e7xDrq9p7OLHSmu1rBKFf6Y1/A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -9223,9 +9223,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-parser": {
|
"node_modules/fast-xml-parser": {
|
||||||
"version": "5.5.6",
|
"version": "5.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.7.tgz",
|
||||||
"integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==",
|
"integrity": "sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -9236,7 +9236,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-xml-builder": "^1.1.4",
|
"fast-xml-builder": "^1.1.4",
|
||||||
"path-expression-matcher": "^1.1.3",
|
"path-expression-matcher": "^1.1.3",
|
||||||
"strnum": "^2.1.2"
|
"strnum": "^2.2.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"fxparser": "src/cli/cli.js"
|
"fxparser": "src/cli/cli.js"
|
||||||
@@ -9411,16 +9411,16 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.3.3",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.15.11",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -9947,9 +9947,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.7",
|
"version": "4.12.14",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
|
||||||
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
|
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
@@ -10698,9 +10698,9 @@
|
|||||||
"integrity": "sha512-IsDqv/D9pjgh7GvwTNvmHF98+nrIcOD17fraXgtx8ivq469y95l5ycLi6SeZAZHdeyD3cGLjYwbDX8SRfWx5fA=="
|
"integrity": "sha512-IsDqv/D9pjgh7GvwTNvmHF98+nrIcOD17fraXgtx8ivq469y95l5ycLi6SeZAZHdeyD3cGLjYwbDX8SRfWx5fA=="
|
||||||
},
|
},
|
||||||
"node_modules/liquidjs": {
|
"node_modules/liquidjs": {
|
||||||
"version": "10.25.0",
|
"version": "10.25.5",
|
||||||
"resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.25.0.tgz",
|
"resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.25.5.tgz",
|
||||||
"integrity": "sha512-XpO7AiGULTG4xcTlwkcTI5JreFG7b6esLCLp+aUSh7YuQErJZEoUXre9u9rbdb0057pfWG4l0VursvLd5Q/eAw==",
|
"integrity": "sha512-GKiKeZjJDdVoQAu+S9rzkYsYnYhcep5W3WwZXgb5f+yq484P/k9JqamBbGYu+LBEixcUAXZr2jogdAIjB3ki1w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^10.0.0"
|
"commander": "^10.0.0"
|
||||||
@@ -10743,16 +10743,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.23",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash-es": {
|
"node_modules/lodash-es": {
|
||||||
"version": "4.17.23",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
|
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||||
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
@@ -12731,9 +12731,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "8.3.0",
|
"version": "8.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.1.tgz",
|
||||||
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
"integrity": "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -12776,9 +12776,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -13224,9 +13224,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/protobufjs": {
|
"node_modules/protobufjs": {
|
||||||
"version": "7.5.4",
|
"version": "7.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz",
|
||||||
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
|
"integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -13261,11 +13261,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pump": {
|
"node_modules/pump": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
@@ -14342,9 +14345,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/smol-toml": {
|
"node_modules/smol-toml": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz",
|
||||||
"integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==",
|
"integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
@@ -14598,9 +14601,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/strnum": {
|
"node_modules/strnum": {
|
||||||
"version": "2.1.2",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.1.tgz",
|
||||||
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
|
"integrity": "sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -15851,9 +15854,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
16
package.json
16
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "blogging-desktop-server",
|
"name": "blogging-desktop-server",
|
||||||
"productName": "Blogging Desktop Server",
|
"productName": "Blogging Desktop Server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"description": "A desktop blogging application with offline-first capabilities and cloud sync",
|
"description": "A desktop blogging application with offline-first capabilities and cloud sync",
|
||||||
"main": "dist/main/main.js",
|
"main": "dist/main/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
"electron": "^40.4.0",
|
"electron": "^40.8.5",
|
||||||
"electron-builder": "^26.7.0",
|
"electron-builder": "^26.7.0",
|
||||||
"eslint": "^9.39.3",
|
"eslint": "^9.39.3",
|
||||||
"eslint-plugin-i18next": "^6.1.3",
|
"eslint-plugin-i18next": "^6.1.3",
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"png-to-ico": "^3.0.1",
|
"png-to-ico": "^3.0.1",
|
||||||
"tsx": "^4.6.0",
|
"tsx": "^4.6.0",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.2",
|
||||||
"vitest": "^4.0.18",
|
"vitest": "^4.0.18",
|
||||||
"wait-on": "^9.0.3"
|
"wait-on": "^9.0.3"
|
||||||
},
|
},
|
||||||
@@ -90,15 +90,15 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@picocss/pico": "^2.1.1",
|
"@picocss/pico": "^2.1.1",
|
||||||
"@xmldom/xmldom": "^0.8.11",
|
"@xmldom/xmldom": "^0.8.12",
|
||||||
"ai": "^6.0.105",
|
"ai": "^6.0.105",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"d3-cloud": "^1.2.8",
|
"d3-cloud": "^1.2.8",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.2",
|
||||||
"fast-xml-parser": "^5.5.6",
|
"fast-xml-parser": "^5.5.7",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"lightbox2": "^2.11.5",
|
"lightbox2": "^2.11.5",
|
||||||
"liquidjs": "^10.25.0",
|
"liquidjs": "^10.25.5",
|
||||||
"marked-react": "^3.0.2",
|
"marked-react": "^3.0.2",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"node-scp": "^0.0.25",
|
"node-scp": "^0.0.25",
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
"rsyncwrapper": "^3.1.0",
|
"rsyncwrapper": "^3.1.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"simple-git": "^3.32.3",
|
"simple-git": "^3.32.3",
|
||||||
"smol-toml": "^1.6.0",
|
"smol-toml": "^1.6.1",
|
||||||
"snowball-stemmers": "^0.6.0",
|
"snowball-stemmers": "^0.6.0",
|
||||||
"transliteration": "^2.6.1",
|
"transliteration": "^2.6.1",
|
||||||
"turndown": "^7.2.2",
|
"turndown": "^7.2.2",
|
||||||
|
|||||||
@@ -294,8 +294,10 @@ export class ChatService {
|
|||||||
// Build tools (skip for Ollama/LM Studio models unless capability override is set)
|
// Build tools (skip for Ollama/LM Studio models unless capability override is set)
|
||||||
const isOllama = this.providers.isOllamaModel(modelId);
|
const isOllama = this.providers.isOllamaModel(modelId);
|
||||||
const isLmstudio = this.providers.isLmstudioModel(modelId);
|
const isLmstudio = this.providers.isLmstudioModel(modelId);
|
||||||
|
const isGenericOpenAI = this.providers.isGenericOpenAIModel(modelId);
|
||||||
const skipTools = (isOllama && !this.providers.ollamaModelSupportsTools(modelId))
|
const skipTools = (isOllama && !this.providers.ollamaModelSupportsTools(modelId))
|
||||||
|| (isLmstudio && !this.providers.lmstudioModelSupportsTools(modelId));
|
|| (isLmstudio && !this.providers.lmstudioModelSupportsTools(modelId))
|
||||||
|
|| (isGenericOpenAI && !this.providers.genericOpenAIModelSupportsTools(modelId));
|
||||||
const blogTools = skipTools ? {} : createBlogTools(this.blogToolDeps);
|
const blogTools = skipTools ? {} : createBlogTools(this.blogToolDeps);
|
||||||
const a2uiToolsRaw = skipTools ? {} : createA2UITools();
|
const a2uiToolsRaw = skipTools ? {} : createA2UITools();
|
||||||
const allTools = { ...blogTools, ...a2uiToolsRaw };
|
const allTools = { ...blogTools, ...a2uiToolsRaw };
|
||||||
|
|||||||
@@ -31,10 +31,12 @@ export const OLLAMA_BASE_URL = 'http://localhost:11434/v1';
|
|||||||
export const OLLAMA_TAGS_URL = 'http://localhost:11434/api/tags';
|
export const OLLAMA_TAGS_URL = 'http://localhost:11434/api/tags';
|
||||||
export const LMSTUDIO_BASE_URL = 'http://localhost:1234/v1';
|
export const LMSTUDIO_BASE_URL = 'http://localhost:1234/v1';
|
||||||
export const LMSTUDIO_MODELS_URL = 'http://localhost:1234/v1/models';
|
export const LMSTUDIO_MODELS_URL = 'http://localhost:1234/v1/models';
|
||||||
|
export const GENERIC_OPENAI_MODELS_PATH = '/models';
|
||||||
|
|
||||||
const MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
const MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
const OLLAMA_FETCH_TIMEOUT = 3000; // 3 s — fail fast when Ollama isn't running
|
const OLLAMA_FETCH_TIMEOUT = 3000; // 3 s — fail fast when Ollama isn't running
|
||||||
const LMSTUDIO_FETCH_TIMEOUT = 3000; // 3 s — fail fast when LM Studio isn't running
|
const LMSTUDIO_FETCH_TIMEOUT = 3000; // 3 s — fail fast when LM Studio isn't running
|
||||||
|
const GENERIC_OPENAI_FETCH_TIMEOUT = 10000; // 10 s for generic endpoints
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Gateway factory
|
// Gateway factory
|
||||||
@@ -123,6 +125,12 @@ export class ProviderRegistry {
|
|||||||
private lmstudioProvider: ReturnType<typeof createOpenAI> | null = null;
|
private lmstudioProvider: ReturnType<typeof createOpenAI> | null = null;
|
||||||
private lmstudioModelIds = new Set<string>();
|
private lmstudioModelIds = new Set<string>();
|
||||||
private lmstudioCapabilities = new Map<string, { tools: boolean; vision: boolean }>();
|
private lmstudioCapabilities = new Map<string, { tools: boolean; vision: boolean }>();
|
||||||
|
private genericOpenAIEnabled = false;
|
||||||
|
private genericOpenAIBaseURL = '';
|
||||||
|
private genericOpenAIApiKey = '';
|
||||||
|
private genericOpenAIProvider: ReturnType<typeof createOpenAI> | null = null;
|
||||||
|
private genericOpenAIModelIds = new Set<string>();
|
||||||
|
private genericOpenAICapabilities = new Map<string, { tools: boolean; vision: boolean; disableThinking: boolean }>();
|
||||||
private modelCatalogEngine = new ModelCatalogEngine();
|
private modelCatalogEngine = new ModelCatalogEngine();
|
||||||
private _offlineMode = false;
|
private _offlineMode = false;
|
||||||
|
|
||||||
@@ -297,9 +305,107 @@ export class ProviderRegistry {
|
|||||||
return this.lmstudioCapabilities.get(modelId)?.vision ?? false;
|
return this.lmstudioCapabilities.get(modelId)?.vision ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Generic OpenAI-compatible endpoint management ----
|
||||||
|
|
||||||
|
setGenericOpenAIEnabled(enabled: boolean): void {
|
||||||
|
this.genericOpenAIEnabled = enabled;
|
||||||
|
this.genericOpenAIProvider = null;
|
||||||
|
this.invalidateModelCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
isGenericOpenAIEnabled(): boolean {
|
||||||
|
return this.genericOpenAIEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGenericOpenAIBaseURL(baseURL: string): void {
|
||||||
|
this.genericOpenAIBaseURL = this.normalizeGenericOpenAIBaseURL(baseURL);
|
||||||
|
this.genericOpenAIProvider = null;
|
||||||
|
this.invalidateModelCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
getGenericOpenAIBaseURL(): string {
|
||||||
|
return this.genericOpenAIBaseURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGenericOpenAIApiKey(apiKey: string): void {
|
||||||
|
this.genericOpenAIApiKey = apiKey;
|
||||||
|
this.genericOpenAIProvider = null;
|
||||||
|
this.invalidateModelCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
getGenericOpenAIApiKey(): string {
|
||||||
|
return this.genericOpenAIApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a model ID as belonging to the generic OpenAI endpoint. */
|
||||||
|
registerGenericOpenAIModel(modelId: string): void {
|
||||||
|
this.genericOpenAIModelIds.add(modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether a model ID was registered as a generic OpenAI model. */
|
||||||
|
isGenericOpenAIModel(modelId: string): boolean {
|
||||||
|
return this.genericOpenAIModelIds.has(modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove all registered generic OpenAI model IDs. */
|
||||||
|
clearGenericOpenAIModels(): void {
|
||||||
|
this.genericOpenAIModelIds.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get capability overrides for a specific generic OpenAI model. */
|
||||||
|
getGenericOpenAIModelCapabilities(modelId: string): { tools: boolean; vision: boolean; disableThinking: boolean } {
|
||||||
|
return this.genericOpenAICapabilities.get(modelId) ?? { tools: false, vision: false, disableThinking: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set capability overrides for a specific generic OpenAI model. */
|
||||||
|
setGenericOpenAIModelCapabilities(modelId: string, caps: { tools: boolean; vision: boolean; disableThinking?: boolean }): void {
|
||||||
|
this.genericOpenAICapabilities.set(modelId, {
|
||||||
|
tools: caps.tools,
|
||||||
|
vision: caps.vision,
|
||||||
|
disableThinking: caps.disableThinking ?? false,
|
||||||
|
});
|
||||||
|
this.invalidateModelCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all stored generic OpenAI capability overrides. */
|
||||||
|
getAllGenericOpenAIModelCapabilities(): Record<string, { tools: boolean; vision: boolean; disableThinking: boolean }> {
|
||||||
|
const result: Record<string, { tools: boolean; vision: boolean; disableThinking: boolean }> = {};
|
||||||
|
for (const [id, caps] of this.genericOpenAICapabilities) {
|
||||||
|
result[id] = caps;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load generic OpenAI capability overrides from serialized object. */
|
||||||
|
loadGenericOpenAIModelCapabilities(data: Record<string, { tools: boolean; vision: boolean; disableThinking?: boolean }>): void {
|
||||||
|
this.genericOpenAICapabilities.clear();
|
||||||
|
for (const [id, caps] of Object.entries(data)) {
|
||||||
|
this.genericOpenAICapabilities.set(id, {
|
||||||
|
tools: caps.tools,
|
||||||
|
vision: caps.vision,
|
||||||
|
disableThinking: caps.disableThinking ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether a generic OpenAI model has tools capability enabled. */
|
||||||
|
genericOpenAIModelSupportsTools(modelId: string): boolean {
|
||||||
|
return this.genericOpenAICapabilities.get(modelId)?.tools ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether a generic OpenAI model has vision capability enabled. */
|
||||||
|
genericOpenAIModelSupportsVision(modelId: string): boolean {
|
||||||
|
return this.genericOpenAICapabilities.get(modelId)?.vision ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether a generic OpenAI model should disable reasoning/thinking output. */
|
||||||
|
genericOpenAIModelDisablesThinking(modelId: string): boolean {
|
||||||
|
return this.genericOpenAICapabilities.get(modelId)?.disableThinking ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect the effective provider for a model ID, checking Ollama and LM Studio
|
* Detect the effective provider for a model ID, checking Ollama, LM Studio,
|
||||||
* registration first, then falling back to prefix-based detection.
|
* and generic OpenAI registration first, then falling back to prefix-based detection.
|
||||||
*/
|
*/
|
||||||
detectModelProvider(modelId: string): string {
|
detectModelProvider(modelId: string): string {
|
||||||
if (this.ollamaModelIds.has(modelId)) {
|
if (this.ollamaModelIds.has(modelId)) {
|
||||||
@@ -308,6 +414,9 @@ export class ProviderRegistry {
|
|||||||
if (this.lmstudioModelIds.has(modelId)) {
|
if (this.lmstudioModelIds.has(modelId)) {
|
||||||
return 'lmstudio';
|
return 'lmstudio';
|
||||||
}
|
}
|
||||||
|
if (this.genericOpenAIModelIds.has(modelId)) {
|
||||||
|
return 'generic-openai';
|
||||||
|
}
|
||||||
return detectProvider(modelId);
|
return detectProvider(modelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +425,7 @@ export class ProviderRegistry {
|
|||||||
if (this._offlineMode) {
|
if (this._offlineMode) {
|
||||||
return !!(this.ollamaEnabled || this.lmstudioEnabled);
|
return !!(this.ollamaEnabled || this.lmstudioEnabled);
|
||||||
}
|
}
|
||||||
return !!(this.opencodeKey || this.mistralKey || this.ollamaEnabled || this.lmstudioEnabled);
|
return !!(this.opencodeKey || this.mistralKey || this.ollamaEnabled || this.lmstudioEnabled || this.genericOpenAIEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check whether the key for a specific provider is set. */
|
/** Check whether the key for a specific provider is set. */
|
||||||
@@ -327,6 +436,9 @@ export class ProviderRegistry {
|
|||||||
if (provider === 'lmstudio') {
|
if (provider === 'lmstudio') {
|
||||||
return this.lmstudioEnabled;
|
return this.lmstudioEnabled;
|
||||||
}
|
}
|
||||||
|
if (provider === 'generic-openai') {
|
||||||
|
return this.genericOpenAIEnabled && Boolean(this.genericOpenAIBaseURL);
|
||||||
|
}
|
||||||
// In offline mode, cloud providers are unavailable
|
// In offline mode, cloud providers are unavailable
|
||||||
if (this._offlineMode) {
|
if (this._offlineMode) {
|
||||||
return false;
|
return false;
|
||||||
@@ -338,12 +450,13 @@ export class ProviderRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Returns status of all configured providers. */
|
/** Returns status of all configured providers. */
|
||||||
getProviderStatus(): { opencode: boolean; mistral: boolean; ollama: boolean; lmstudio: boolean; offlineMode: boolean } {
|
getProviderStatus(): { opencode: boolean; mistral: boolean; ollama: boolean; lmstudio: boolean; genericOpenAI: boolean; offlineMode: boolean } {
|
||||||
return {
|
return {
|
||||||
opencode: !!this.opencodeKey,
|
opencode: !!this.opencodeKey,
|
||||||
mistral: !!this.mistralKey,
|
mistral: !!this.mistralKey,
|
||||||
ollama: this.ollamaEnabled,
|
ollama: this.ollamaEnabled,
|
||||||
lmstudio: this.lmstudioEnabled,
|
lmstudio: this.lmstudioEnabled,
|
||||||
|
genericOpenAI: this.genericOpenAIEnabled,
|
||||||
offlineMode: this._offlineMode,
|
offlineMode: this._offlineMode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -385,6 +498,52 @@ export class ProviderRegistry {
|
|||||||
return this.lmstudioProvider.chat(modelId);
|
return this.lmstudioProvider.chat(modelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is a registered generic OpenAI model
|
||||||
|
if (this.genericOpenAIModelIds.has(modelId)) {
|
||||||
|
if (!this.genericOpenAIEnabled || !this.genericOpenAIBaseURL) {
|
||||||
|
throw new Error(`Generic OpenAI endpoint not configured for model '${modelId}'`);
|
||||||
|
}
|
||||||
|
if (!this.genericOpenAIProvider) {
|
||||||
|
const genericFetch: typeof fetch = async (input, init) => {
|
||||||
|
const url = typeof input === 'string'
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: input.url;
|
||||||
|
|
||||||
|
if (url.endsWith('/chat/completions') && typeof init?.body === 'string') {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(init.body) as { model?: string; chat_template_kwargs?: Record<string, unknown> };
|
||||||
|
if (body.model && this.genericOpenAIModelDisablesThinking(body.model)) {
|
||||||
|
const nextInit = {
|
||||||
|
...init,
|
||||||
|
body: JSON.stringify({
|
||||||
|
...body,
|
||||||
|
chat_template_kwargs: {
|
||||||
|
...body.chat_template_kwargs,
|
||||||
|
enable_thinking: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return fetch(input, nextInit);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to the original request if the body isn't JSON.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(input, init);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.genericOpenAIProvider = createOpenAI({
|
||||||
|
baseURL: this.genericOpenAIBaseURL,
|
||||||
|
apiKey: this.genericOpenAIApiKey || 'dummy-key',
|
||||||
|
fetch: genericFetch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.genericOpenAIProvider.chat(modelId);
|
||||||
|
}
|
||||||
|
|
||||||
const provider = detectProvider(modelId);
|
const provider = detectProvider(modelId);
|
||||||
|
|
||||||
if (provider === 'mistral') {
|
if (provider === 'mistral') {
|
||||||
@@ -544,6 +703,19 @@ export class ProviderRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch generic OpenAI-compatible endpoint models
|
||||||
|
if (this.genericOpenAIEnabled && this.genericOpenAIBaseURL && !this._offlineMode) {
|
||||||
|
try {
|
||||||
|
const models = await this.fetchGenericOpenAIModels();
|
||||||
|
allModels.push(...models);
|
||||||
|
if (models.length > 0) {
|
||||||
|
fetched = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Generic OpenAI endpoint not available — skip silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (fetched && allModels.length > 0) {
|
if (fetched && allModels.length > 0) {
|
||||||
this.cachedModels = allModels;
|
this.cachedModels = allModels;
|
||||||
this.cachedModelsAt = Date.now();
|
this.cachedModelsAt = Date.now();
|
||||||
@@ -678,6 +850,78 @@ export class ProviderRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Generic OpenAI-compatible endpoint model listing ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch available models from a generic OpenAI-compatible /v1/models endpoint.
|
||||||
|
* Returns ChatModel[] and registers the model IDs internally.
|
||||||
|
*/
|
||||||
|
async fetchGenericOpenAIModels(): Promise<ChatModel[]> {
|
||||||
|
const normalizedBaseURL = this.normalizeGenericOpenAIBaseURL(this.genericOpenAIBaseURL);
|
||||||
|
if (!normalizedBaseURL) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), GENERIC_OPENAI_FETCH_TIMEOUT);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (this.genericOpenAIApiKey) {
|
||||||
|
headers.Authorization = `Bearer ${this.genericOpenAIApiKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${normalizedBaseURL}${GENERIC_OPENAI_MODELS_PATH}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as { data?: Array<{ id: string }> };
|
||||||
|
if (!data.data || !Array.isArray(data.data)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const models: ChatModel[] = data.data.map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
name: m.id,
|
||||||
|
provider: 'generic-openai',
|
||||||
|
vision: this.genericOpenAIModelSupportsVision(m.id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Only replace registered IDs on successful fetch
|
||||||
|
this.clearGenericOpenAIModels();
|
||||||
|
for (const m of models) {
|
||||||
|
this.registerGenericOpenAIModel(m.id);
|
||||||
|
}
|
||||||
|
return models;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate generic OpenAI endpoint configuration by fetching models.
|
||||||
|
*/
|
||||||
|
async validateGenericOpenAIConfig(): Promise<{ isValid: boolean; models: ChatModel[]; error?: string }> {
|
||||||
|
if (!this.genericOpenAIBaseURL) {
|
||||||
|
return { isValid: false, models: [], error: 'Base URL is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const models = await this.fetchGenericOpenAIModels();
|
||||||
|
return { isValid: true, models };
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
return { isValid: false, models: [], error: errorMessage };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Private helpers ----
|
// ---- Private helpers ----
|
||||||
|
|
||||||
private async fetchModelsFromEndpoint(
|
private async fetchModelsFromEndpoint(
|
||||||
@@ -725,6 +969,14 @@ export class ProviderRegistry {
|
|||||||
return { vision, names };
|
return { vision, names };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeGenericOpenAIBaseURL(baseURL: string): string {
|
||||||
|
const trimmed = baseURL.trim().replace(/\/+$/, '');
|
||||||
|
if (!trimmed) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`;
|
||||||
|
}
|
||||||
|
|
||||||
private async getModelsFromCatalog(): Promise<ChatModel[]> {
|
private async getModelsFromCatalog(): Promise<ChatModel[]> {
|
||||||
try {
|
try {
|
||||||
const catalog = await this.modelCatalogEngine.getAll();
|
const catalog = await this.modelCatalogEngine.getAll();
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ const SHARED_JS = `\
|
|||||||
document.getElementById("status").textContent = "Error: App bundle not loaded"; document.getElementById("status").className = "status status-error"; document.getElementById("status").style.display = "block"; throw new Error("App bundle not loaded");
|
document.getElementById("status").textContent = "Error: App bundle not loaded"; document.getElementById("status").className = "status status-error"; document.getElementById("status").style.display = "block"; throw new Error("App bundle not loaded");
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = new App({ name: "bDS Review", version: "1.0.0" });
|
const app = new App({ name: "bDS Review", version: "1.0.1" });
|
||||||
|
|
||||||
let currentData = null;
|
let currentData = null;
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,13 @@ async function ensureInitialized(): Promise<void> {
|
|||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const genericOpenAIKey = await keyStore.retrieve('generic_openai_api_key');
|
||||||
|
if (genericOpenAIKey) {
|
||||||
|
reg.setGenericOpenAIApiKey(genericOpenAIKey);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
// Restore Ollama enabled state from settings DB
|
// Restore Ollama enabled state from settings DB
|
||||||
try {
|
try {
|
||||||
const ollamaEnabled = await getChatEngine().getSetting('ollama_enabled');
|
const ollamaEnabled = await getChatEngine().getSetting('ollama_enabled');
|
||||||
@@ -167,6 +174,21 @@ async function ensureInitialized(): Promise<void> {
|
|||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
// Restore generic OpenAI enabled state and base URL from settings DB
|
||||||
|
try {
|
||||||
|
const genericOpenAIEnabled = await getChatEngine().getSetting('generic_openai_enabled');
|
||||||
|
if (genericOpenAIEnabled === 'true') {
|
||||||
|
reg.setGenericOpenAIEnabled(true);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const genericOpenAIBaseURL = await getChatEngine().getSetting('generic_openai_base_url');
|
||||||
|
if (genericOpenAIBaseURL) {
|
||||||
|
reg.setGenericOpenAIBaseURL(genericOpenAIBaseURL);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
// Restore LM Studio model capability overrides
|
// Restore LM Studio model capability overrides
|
||||||
try {
|
try {
|
||||||
const lmCapsJson = await getChatEngine().getSetting('lmstudio_model_capabilities');
|
const lmCapsJson = await getChatEngine().getSetting('lmstudio_model_capabilities');
|
||||||
@@ -176,6 +198,15 @@ async function ensureInitialized(): Promise<void> {
|
|||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
// Restore generic OpenAI model capability overrides
|
||||||
|
try {
|
||||||
|
const genericCapsJson = await getChatEngine().getSetting('generic_openai_model_capabilities');
|
||||||
|
if (genericCapsJson) {
|
||||||
|
const caps = JSON.parse(genericCapsJson) as Record<string, { tools: boolean; vision: boolean }>;
|
||||||
|
reg.loadGenericOpenAIModelCapabilities(caps);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
// Restore known LM Studio model IDs (so offline mode works without a fresh fetch)
|
// Restore known LM Studio model IDs (so offline mode works without a fresh fetch)
|
||||||
try {
|
try {
|
||||||
const lmIds = await getChatEngine().getSetting('lmstudio_known_model_ids');
|
const lmIds = await getChatEngine().getSetting('lmstudio_known_model_ids');
|
||||||
@@ -186,6 +217,16 @@ async function ensureInitialized(): Promise<void> {
|
|||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
// Restore known generic OpenAI model IDs for provider routing before a refresh
|
||||||
|
try {
|
||||||
|
const genericIds = await getChatEngine().getSetting('generic_openai_known_model_ids');
|
||||||
|
if (genericIds) {
|
||||||
|
for (const id of JSON.parse(genericIds) as string[]) {
|
||||||
|
reg.registerGenericOpenAIModel(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
// Restore offline mode from settings or auto-detect via OS network status
|
// Restore offline mode from settings or auto-detect via OS network status
|
||||||
try {
|
try {
|
||||||
const savedOffline = await getChatEngine().getSetting('offline_mode');
|
const savedOffline = await getChatEngine().getSetting('offline_mode');
|
||||||
@@ -468,6 +509,147 @@ export function registerChatHandlers(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ Generic OpenAI-compatible Endpoint ============
|
||||||
|
|
||||||
|
// Get generic OpenAI enabled state
|
||||||
|
ipcMain.handle('chat:getGenericOpenAIEnabled', async () => {
|
||||||
|
try {
|
||||||
|
await ensureInitialized();
|
||||||
|
return getProviders().isGenericOpenAIEnabled();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chat IPC] Error getting generic OpenAI enabled state:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set generic OpenAI enabled state
|
||||||
|
ipcMain.handle('chat:setGenericOpenAIEnabled', async (_, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
await ensureInitialized();
|
||||||
|
const reg = getProviders();
|
||||||
|
reg.setGenericOpenAIEnabled(enabled);
|
||||||
|
await getChatEngine().setSetting('generic_openai_enabled', enabled ? 'true' : 'false');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chat IPC] Error setting generic OpenAI enabled state:', error);
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get generic OpenAI base URL
|
||||||
|
ipcMain.handle('chat:getGenericOpenAIBaseURL', async () => {
|
||||||
|
try {
|
||||||
|
await ensureInitialized();
|
||||||
|
return getProviders().getGenericOpenAIBaseURL();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chat IPC] Error getting generic OpenAI base URL:', error);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set generic OpenAI base URL
|
||||||
|
ipcMain.handle('chat:setGenericOpenAIBaseURL', async (_, baseURL: string) => {
|
||||||
|
try {
|
||||||
|
await ensureInitialized();
|
||||||
|
const reg = getProviders();
|
||||||
|
reg.setGenericOpenAIBaseURL(baseURL);
|
||||||
|
await getChatEngine().setSetting('generic_openai_base_url', baseURL);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chat IPC] Error setting generic OpenAI base URL:', error);
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get generic OpenAI API key (masked)
|
||||||
|
ipcMain.handle('chat:getGenericOpenAIApiKey', async () => {
|
||||||
|
try {
|
||||||
|
await ensureInitialized();
|
||||||
|
const key = getProviders().getGenericOpenAIApiKey();
|
||||||
|
if (!key) {
|
||||||
|
return { hasKey: false, maskedKey: '' };
|
||||||
|
}
|
||||||
|
const masked = '•'.repeat(Math.max(0, key.length - 4)) + key.slice(-4);
|
||||||
|
return { hasKey: true, maskedKey: masked };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chat IPC] Error getting generic OpenAI API key:', error);
|
||||||
|
return { hasKey: false, maskedKey: '' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set generic OpenAI API key
|
||||||
|
ipcMain.handle('chat:setGenericOpenAIApiKey', async (_, apiKey: string) => {
|
||||||
|
try {
|
||||||
|
await ensureInitialized();
|
||||||
|
const reg = getProviders();
|
||||||
|
const previousKey = reg.getGenericOpenAIApiKey();
|
||||||
|
reg.setGenericOpenAIApiKey(apiKey);
|
||||||
|
|
||||||
|
// Persist to encrypted storage — roll back in-memory key on failure
|
||||||
|
try {
|
||||||
|
await getSecureKeyStore().store('generic_openai_api_key', apiKey);
|
||||||
|
} catch (storeError) {
|
||||||
|
reg.setGenericOpenAIApiKey(previousKey);
|
||||||
|
throw storeError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chat IPC] Error setting generic OpenAI API key:', error);
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate generic OpenAI configuration
|
||||||
|
ipcMain.handle('chat:validateGenericOpenAIConfig', async () => {
|
||||||
|
try {
|
||||||
|
await ensureInitialized();
|
||||||
|
return await getProviders().validateGenericOpenAIConfig();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chat IPC] Error validating generic OpenAI config:', error);
|
||||||
|
return { isValid: false, models: [], error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch generic OpenAI models
|
||||||
|
ipcMain.handle('chat:getGenericOpenAIModels', async () => {
|
||||||
|
try {
|
||||||
|
await ensureInitialized();
|
||||||
|
return await getProviders().fetchGenericOpenAIModels();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chat IPC] Error fetching generic OpenAI models:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get generic OpenAI model capability overrides
|
||||||
|
ipcMain.handle('chat:getGenericOpenAIModelCapabilities', async () => {
|
||||||
|
try {
|
||||||
|
await ensureInitialized();
|
||||||
|
return getProviders().getAllGenericOpenAIModelCapabilities();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chat IPC] Error getting generic OpenAI model capabilities:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set capability override for a single generic OpenAI model
|
||||||
|
ipcMain.handle('chat:setGenericOpenAIModelCapabilities', async (_, modelId: string, caps: { tools: boolean; vision: boolean; disableThinking: boolean }) => {
|
||||||
|
try {
|
||||||
|
await ensureInitialized();
|
||||||
|
const reg = getProviders();
|
||||||
|
reg.setGenericOpenAIModelCapabilities(modelId, caps);
|
||||||
|
|
||||||
|
// Persist all capabilities to settings DB
|
||||||
|
const allCaps = reg.getAllGenericOpenAIModelCapabilities();
|
||||||
|
await getChatEngine().setSetting('generic_openai_model_capabilities', JSON.stringify(allCaps));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chat IPC] Error setting generic OpenAI model capabilities:', error);
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============ Offline / Airplane Mode ============
|
// ============ Offline / Airplane Mode ============
|
||||||
|
|
||||||
ipcMain.handle('chat:getOfflineMode', async () => {
|
ipcMain.handle('chat:getOfflineMode', async () => {
|
||||||
@@ -627,12 +809,16 @@ export function registerChatHandlers(): void {
|
|||||||
// Persist known local model IDs so offline mode survives restarts
|
// Persist known local model IDs so offline mode survives restarts
|
||||||
const ollamaModels = models.filter(m => m.provider === 'ollama').map(m => m.id);
|
const ollamaModels = models.filter(m => m.provider === 'ollama').map(m => m.id);
|
||||||
const lmstudioModels = models.filter(m => m.provider === 'lmstudio').map(m => m.id);
|
const lmstudioModels = models.filter(m => m.provider === 'lmstudio').map(m => m.id);
|
||||||
|
const genericOpenAIModels = models.filter(m => m.provider === 'generic-openai').map(m => m.id);
|
||||||
if (ollamaModels.length > 0) {
|
if (ollamaModels.length > 0) {
|
||||||
await engine.setSetting('ollama_known_model_ids', JSON.stringify(ollamaModels)).catch(() => {});
|
await engine.setSetting('ollama_known_model_ids', JSON.stringify(ollamaModels)).catch(() => {});
|
||||||
}
|
}
|
||||||
if (lmstudioModels.length > 0) {
|
if (lmstudioModels.length > 0) {
|
||||||
await engine.setSetting('lmstudio_known_model_ids', JSON.stringify(lmstudioModels)).catch(() => {});
|
await engine.setSetting('lmstudio_known_model_ids', JSON.stringify(lmstudioModels)).catch(() => {});
|
||||||
}
|
}
|
||||||
|
if (genericOpenAIModels.length > 0) {
|
||||||
|
await engine.setSetting('generic_openai_known_model_ids', JSON.stringify(genericOpenAIModels)).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, models, selectedModel };
|
return { success: true, models, selectedModel };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -357,6 +357,18 @@ export const electronAPI: ElectronAPI = {
|
|||||||
getLmstudioModelCapabilities: () => ipcRenderer.invoke('chat:getLmstudioModelCapabilities'),
|
getLmstudioModelCapabilities: () => ipcRenderer.invoke('chat:getLmstudioModelCapabilities'),
|
||||||
setLmstudioModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => ipcRenderer.invoke('chat:setLmstudioModelCapabilities', modelId, caps),
|
setLmstudioModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => ipcRenderer.invoke('chat:setLmstudioModelCapabilities', modelId, caps),
|
||||||
|
|
||||||
|
// Generic OpenAI-compatible Endpoint
|
||||||
|
getGenericOpenAIEnabled: () => ipcRenderer.invoke('chat:getGenericOpenAIEnabled'),
|
||||||
|
setGenericOpenAIEnabled: (enabled: boolean) => ipcRenderer.invoke('chat:setGenericOpenAIEnabled', enabled),
|
||||||
|
getGenericOpenAIBaseURL: () => ipcRenderer.invoke('chat:getGenericOpenAIBaseURL'),
|
||||||
|
setGenericOpenAIBaseURL: (baseURL: string) => ipcRenderer.invoke('chat:setGenericOpenAIBaseURL', baseURL),
|
||||||
|
getGenericOpenAIApiKey: () => ipcRenderer.invoke('chat:getGenericOpenAIApiKey'),
|
||||||
|
setGenericOpenAIApiKey: (apiKey: string) => ipcRenderer.invoke('chat:setGenericOpenAIApiKey', apiKey),
|
||||||
|
validateGenericOpenAIConfig: () => ipcRenderer.invoke('chat:validateGenericOpenAIConfig'),
|
||||||
|
getGenericOpenAIModels: () => ipcRenderer.invoke('chat:getGenericOpenAIModels'),
|
||||||
|
getGenericOpenAIModelCapabilities: () => ipcRenderer.invoke('chat:getGenericOpenAIModelCapabilities'),
|
||||||
|
setGenericOpenAIModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean; disableThinking: boolean }) => ipcRenderer.invoke('chat:setGenericOpenAIModelCapabilities', modelId, caps),
|
||||||
|
|
||||||
// Offline / Airplane Mode
|
// Offline / Airplane Mode
|
||||||
getOfflineMode: () => ipcRenderer.invoke('chat:getOfflineMode'),
|
getOfflineMode: () => ipcRenderer.invoke('chat:getOfflineMode'),
|
||||||
setOfflineMode: (enabled: boolean) => ipcRenderer.invoke('chat:setOfflineMode', enabled),
|
setOfflineMode: (enabled: boolean) => ipcRenderer.invoke('chat:setOfflineMode', enabled),
|
||||||
|
|||||||
@@ -495,7 +495,7 @@ export interface ChatReadyStatus {
|
|||||||
ready: boolean;
|
ready: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
backend?: string;
|
backend?: string;
|
||||||
providers?: { opencode: boolean; mistral: boolean; ollama: boolean; lmstudio: boolean; offlineMode: boolean };
|
providers?: { opencode: boolean; mistral: boolean; ollama: boolean; lmstudio: boolean; genericOpenAI: boolean; offlineMode: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatApiKeyStatus {
|
export interface ChatApiKeyStatus {
|
||||||
@@ -1022,6 +1022,18 @@ export interface ElectronAPI {
|
|||||||
getLmstudioModelCapabilities: () => Promise<Record<string, { tools: boolean; vision: boolean }>>;
|
getLmstudioModelCapabilities: () => Promise<Record<string, { tools: boolean; vision: boolean }>>;
|
||||||
setLmstudioModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => Promise<{ success: boolean; error?: string }>;
|
setLmstudioModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
|
||||||
|
// Generic OpenAI-compatible endpoint
|
||||||
|
getGenericOpenAIEnabled: () => Promise<boolean>;
|
||||||
|
setGenericOpenAIEnabled: (enabled: boolean) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
getGenericOpenAIBaseURL: () => Promise<string>;
|
||||||
|
setGenericOpenAIBaseURL: (baseURL: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
getGenericOpenAIApiKey: () => Promise<ChatApiKeyStatus>;
|
||||||
|
setGenericOpenAIApiKey: (apiKey: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
validateGenericOpenAIConfig: () => Promise<{ isValid: boolean; models: ChatModel[]; error?: string }>;
|
||||||
|
getGenericOpenAIModels: () => Promise<ChatModel[]>;
|
||||||
|
getGenericOpenAIModelCapabilities: () => Promise<Record<string, { tools: boolean; vision: boolean; disableThinking: boolean }>>;
|
||||||
|
setGenericOpenAIModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean; disableThinking: boolean }) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
|
||||||
// Offline / Airplane mode
|
// Offline / Airplane mode
|
||||||
getOfflineMode: () => Promise<boolean>;
|
getOfflineMode: () => Promise<boolean>;
|
||||||
setOfflineMode: (enabled: boolean) => Promise<{ success: boolean; error?: string }>;
|
setOfflineMode: (enabled: boolean) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
|||||||
@@ -253,6 +253,13 @@ export const SettingsView: React.FC = () => {
|
|||||||
const [lmstudioEnabled, setLmstudioEnabled] = useState(false);
|
const [lmstudioEnabled, setLmstudioEnabled] = useState(false);
|
||||||
const [lmstudioCapabilities, setLmstudioCapabilities] = useState<Record<string, { tools: boolean; vision: boolean }>>({});
|
const [lmstudioCapabilities, setLmstudioCapabilities] = useState<Record<string, { tools: boolean; vision: boolean }>>({});
|
||||||
const [lmstudioModels, setLmstudioModels] = useState<{id: string; name: string}[]>([]);
|
const [lmstudioModels, setLmstudioModels] = useState<{id: string; name: string}[]>([]);
|
||||||
|
const [genericOpenAIEnabled, setGenericOpenAIEnabled] = useState(false);
|
||||||
|
const [genericOpenAIBaseURL, setGenericOpenAIBaseURL] = useState('');
|
||||||
|
const [genericOpenAIApiKeyMasked, setGenericOpenAIApiKeyMasked] = useState('');
|
||||||
|
const [hasGenericOpenAIApiKey, setHasGenericOpenAIApiKey] = useState(false);
|
||||||
|
const [newGenericOpenAIApiKey, setNewGenericOpenAIApiKey] = useState('');
|
||||||
|
const [genericOpenAIModels, setGenericOpenAIModels] = useState<{id: string; name: string}[]>([]);
|
||||||
|
const [genericOpenAICapabilities, setGenericOpenAICapabilities] = useState<Record<string, { tools: boolean; vision: boolean; disableThinking: boolean }>>({});
|
||||||
const [offlineModeEnabled, setOfflineModeEnabled] = useState(false);
|
const [offlineModeEnabled, setOfflineModeEnabled] = useState(false);
|
||||||
const [offlineChatModel, setOfflineChatModel] = useState('');
|
const [offlineChatModel, setOfflineChatModel] = useState('');
|
||||||
const [offlineTitleModel, setOfflineTitleModel] = useState('');
|
const [offlineTitleModel, setOfflineTitleModel] = useState('');
|
||||||
@@ -464,6 +471,33 @@ export const SettingsView: React.FC = () => {
|
|||||||
if (lmModels) setLmstudioModels(lmModels.map(m => ({ id: m.id, name: m.name })));
|
if (lmModels) setLmstudioModels(lmModels.map(m => ({ id: m.id, name: m.name })));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load generic OpenAI enabled state
|
||||||
|
const genericOpenAIState = await window.electronAPI?.chat.getGenericOpenAIEnabled();
|
||||||
|
setGenericOpenAIEnabled(!!genericOpenAIState);
|
||||||
|
|
||||||
|
// Load generic OpenAI base URL
|
||||||
|
const genericOpenAIBaseURLResult = await window.electronAPI?.chat.getGenericOpenAIBaseURL();
|
||||||
|
if (genericOpenAIBaseURLResult) {
|
||||||
|
setGenericOpenAIBaseURL(genericOpenAIBaseURLResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load generic OpenAI API key status
|
||||||
|
const genericOpenAIApiKeyResult = await window.electronAPI?.chat.getGenericOpenAIApiKey();
|
||||||
|
if (genericOpenAIApiKeyResult) {
|
||||||
|
setHasGenericOpenAIApiKey(genericOpenAIApiKeyResult.hasKey);
|
||||||
|
setGenericOpenAIApiKeyMasked(genericOpenAIApiKeyResult.maskedKey || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load generic OpenAI model capabilities and models list
|
||||||
|
if (genericOpenAIState) {
|
||||||
|
const [genCaps, genModels] = await Promise.all([
|
||||||
|
window.electronAPI?.chat.getGenericOpenAIModelCapabilities(),
|
||||||
|
window.electronAPI?.chat.getGenericOpenAIModels(),
|
||||||
|
]);
|
||||||
|
if (genCaps) setGenericOpenAICapabilities(genCaps);
|
||||||
|
if (genModels) setGenericOpenAIModels(genModels.map(m => ({ id: m.id, name: m.name })));
|
||||||
|
}
|
||||||
|
|
||||||
// Load per-purpose model preferences
|
// Load per-purpose model preferences
|
||||||
const titleModelResult = await window.electronAPI?.chat.getTitleModel();
|
const titleModelResult = await window.electronAPI?.chat.getTitleModel();
|
||||||
if (titleModelResult?.success && titleModelResult.modelId) {
|
if (titleModelResult?.success && titleModelResult.modelId) {
|
||||||
@@ -1340,6 +1374,116 @@ export const SettingsView: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Generic OpenAI handlers
|
||||||
|
const handleGenericOpenAIToggle = async (enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI?.chat.setGenericOpenAIEnabled(enabled);
|
||||||
|
if (result?.success) {
|
||||||
|
setGenericOpenAIEnabled(enabled);
|
||||||
|
showToast.success(t(enabled ? 'settings.toast.genericOpenAIEnabled' : 'settings.toast.genericOpenAIDisabled'));
|
||||||
|
|
||||||
|
// Refresh models after toggle
|
||||||
|
const modelsResult = await window.electronAPI?.chat.getAvailableModels();
|
||||||
|
if (modelsResult?.success && modelsResult.models) {
|
||||||
|
setAvailableModels(modelsResult.models);
|
||||||
|
setSelectedModel(modelsResult.selectedModel || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load generic OpenAI models and capabilities when enabling
|
||||||
|
if (enabled) {
|
||||||
|
const [caps, genModelsList] = await Promise.all([
|
||||||
|
window.electronAPI?.chat.getGenericOpenAIModelCapabilities(),
|
||||||
|
window.electronAPI?.chat.getGenericOpenAIModels(),
|
||||||
|
]);
|
||||||
|
if (caps) setGenericOpenAICapabilities(caps);
|
||||||
|
if (genModelsList) setGenericOpenAIModels(genModelsList.map(m => ({ id: m.id, name: m.name })));
|
||||||
|
} else {
|
||||||
|
setGenericOpenAIModels([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to toggle generic OpenAI:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveGenericOpenAIBaseURL = async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI?.chat.setGenericOpenAIBaseURL(genericOpenAIBaseURL);
|
||||||
|
if (!result?.success) {
|
||||||
|
throw new Error(result?.error || 'Failed to save generic OpenAI base URL');
|
||||||
|
}
|
||||||
|
const storedBaseURL = await window.electronAPI?.chat.getGenericOpenAIBaseURL();
|
||||||
|
if (typeof storedBaseURL === 'string') {
|
||||||
|
setGenericOpenAIBaseURL(storedBaseURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [caps, genModelsList, modelsResult] = await Promise.all([
|
||||||
|
window.electronAPI?.chat.getGenericOpenAIModelCapabilities(),
|
||||||
|
window.electronAPI?.chat.getGenericOpenAIModels(),
|
||||||
|
window.electronAPI?.chat.getAvailableModels(),
|
||||||
|
]);
|
||||||
|
setGenericOpenAICapabilities(caps || {});
|
||||||
|
setGenericOpenAIModels((genModelsList || []).map(m => ({ id: m.id, name: m.name })));
|
||||||
|
if (modelsResult?.success && modelsResult.models) {
|
||||||
|
setAvailableModels(modelsResult.models);
|
||||||
|
setSelectedModel(modelsResult.selectedModel || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast.success(t('settings.toast.genericOpenAISettingsSaved'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save generic OpenAI base URL:', error);
|
||||||
|
showToast.error(t('settings.toast.genericOpenAISettingsSaveFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveGenericOpenAIApiKey = async () => {
|
||||||
|
if (!newGenericOpenAIApiKey.trim()) return;
|
||||||
|
try {
|
||||||
|
const trimmedKey = newGenericOpenAIApiKey.trim();
|
||||||
|
const result = await window.electronAPI?.chat.setGenericOpenAIApiKey(trimmedKey);
|
||||||
|
if (!result?.success) {
|
||||||
|
throw new Error(result?.error || 'Failed to save generic OpenAI API key');
|
||||||
|
}
|
||||||
|
setHasGenericOpenAIApiKey(true);
|
||||||
|
setGenericOpenAIApiKeyMasked('•'.repeat(Math.max(0, trimmedKey.length - 4)) + trimmedKey.slice(-4));
|
||||||
|
setNewGenericOpenAIApiKey('');
|
||||||
|
showToast.success(t('settings.toast.apiKeySaved'));
|
||||||
|
|
||||||
|
const [caps, genModelsList, modelsResult] = await Promise.all([
|
||||||
|
window.electronAPI?.chat.getGenericOpenAIModelCapabilities(),
|
||||||
|
window.electronAPI?.chat.getGenericOpenAIModels(),
|
||||||
|
window.electronAPI?.chat.getAvailableModels(),
|
||||||
|
]);
|
||||||
|
setGenericOpenAICapabilities(caps || {});
|
||||||
|
setGenericOpenAIModels((genModelsList || []).map(m => ({ id: m.id, name: m.name })));
|
||||||
|
if (modelsResult?.success && modelsResult.models) {
|
||||||
|
setAvailableModels(modelsResult.models);
|
||||||
|
setSelectedModel(modelsResult.selectedModel || '');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save generic OpenAI API key:', error);
|
||||||
|
showToast.error(t('settings.toast.apiKeySaveFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenericOpenAICapabilityToggle = async (modelId: string, field: 'tools' | 'vision' | 'disableThinking', value: boolean) => {
|
||||||
|
const current = genericOpenAICapabilities[modelId] ?? { tools: false, vision: false, disableThinking: false };
|
||||||
|
const updated = { ...current, [field]: value };
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI?.chat.setGenericOpenAIModelCapabilities(modelId, updated);
|
||||||
|
if (result?.success) {
|
||||||
|
setGenericOpenAICapabilities(prev => ({ ...prev, [modelId]: updated }));
|
||||||
|
// Refresh available models to reflect vision change
|
||||||
|
const modelsResult = await window.electronAPI?.chat.getAvailableModels();
|
||||||
|
if (modelsResult?.success && modelsResult.models) {
|
||||||
|
setAvailableModels(modelsResult.models);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update generic OpenAI model capabilities:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleTitleModelChange = async (modelId: string) => {
|
const handleTitleModelChange = async (modelId: string) => {
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI?.chat.setTitleModel(modelId);
|
const result = await window.electronAPI?.chat.setTitleModel(modelId);
|
||||||
@@ -1488,6 +1632,7 @@ export const SettingsView: React.FC = () => {
|
|||||||
if (provider === 'mistral') return t('settings.ai.providerMistral');
|
if (provider === 'mistral') return t('settings.ai.providerMistral');
|
||||||
if (provider === 'ollama') return t('settings.ai.providerOllama');
|
if (provider === 'ollama') return t('settings.ai.providerOllama');
|
||||||
if (provider === 'lmstudio') return t('settings.ai.providerLmstudio');
|
if (provider === 'lmstudio') return t('settings.ai.providerLmstudio');
|
||||||
|
if (provider === 'generic-openai') return t('settings.ai.providerGenericOpenAI');
|
||||||
return provider;
|
return provider;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1716,6 +1861,125 @@ export const SettingsView: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
id="ai-generic-openai"
|
||||||
|
label={t('settings.ai.genericOpenAILabel')}
|
||||||
|
description={t('settings.ai.genericOpenOIDescription')}
|
||||||
|
>
|
||||||
|
<div className="setting-input-group">
|
||||||
|
<label className="toggle-label">
|
||||||
|
<input
|
||||||
|
id="ai-generic-openai"
|
||||||
|
type="checkbox"
|
||||||
|
checked={genericOpenAIEnabled}
|
||||||
|
onChange={(e) => handleGenericOpenAIToggle(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t('settings.ai.genericOpenAIEnable')}
|
||||||
|
</label>
|
||||||
|
{genericOpenAIEnabled && (
|
||||||
|
<span className="setting-status-badge success">{t('settings.ai.configured')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{genericOpenAIEnabled && (
|
||||||
|
<div className="generic-openai-settings">
|
||||||
|
<div className="setting-field">
|
||||||
|
<label htmlFor="ai-generic-openai-base-url">{t('settings.ai.genericOpenAIBaseUrlLabel')}</label>
|
||||||
|
<small className="setting-description">{t('settings.ai.genericOpenAIBaseUrlDescription')}</small>
|
||||||
|
<input
|
||||||
|
id="ai-generic-openai-base-url"
|
||||||
|
type="text"
|
||||||
|
value={genericOpenAIBaseURL}
|
||||||
|
onChange={(e) => setGenericOpenAIBaseURL(e.target.value)}
|
||||||
|
placeholder="https://api.example.com/v1"
|
||||||
|
/>
|
||||||
|
<button className="secondary" onClick={handleSaveGenericOpenAIBaseURL}>
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="setting-field">
|
||||||
|
<label htmlFor="ai-generic-openai-api-key">{t('settings.ai.genericOpenAIApiKeyLabel')}</label>
|
||||||
|
<small className="setting-description">{t('settings.ai.genericOpenAIApiKeyDescription')}</small>
|
||||||
|
{hasGenericOpenAIApiKey ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
id="ai-generic-openai-api-key"
|
||||||
|
type="text"
|
||||||
|
value={genericOpenAIApiKeyMasked}
|
||||||
|
disabled
|
||||||
|
placeholder={t('settings.ai.genericOpenAIApiKeyConfigured')}
|
||||||
|
/>
|
||||||
|
<span className="setting-status-badge success">{t('settings.ai.configured')}</span>
|
||||||
|
<div className="setting-inline-action">
|
||||||
|
<button className="text-button" onClick={() => { setHasGenericOpenAIApiKey(false); setGenericOpenAIApiKeyMasked(''); }}>
|
||||||
|
{t('settings.ai.changeApiKey')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
id="ai-generic-openai-api-key"
|
||||||
|
type="password"
|
||||||
|
value={newGenericOpenAIApiKey}
|
||||||
|
onChange={(e) => setNewGenericOpenAIApiKey(e.target.value)}
|
||||||
|
placeholder={t('chat.apiKeyPlaceholder')}
|
||||||
|
/>
|
||||||
|
<button className="primary" onClick={handleSaveGenericOpenAIApiKey} disabled={!newGenericOpenAIApiKey.trim()}>
|
||||||
|
{t('chat.apiKeySave')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{genericOpenAIEnabled && genericOpenAIModels.length > 0 && (
|
||||||
|
<div className="generic-openai-model-capabilities">
|
||||||
|
<small className="setting-description">{t('settings.ai.genericOpenAICapabilitiesDescription')}</small>
|
||||||
|
<table className="generic-openai-caps-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('settings.ai.genericOpenAICapModel')}</th>
|
||||||
|
<th>{t('settings.ai.genericOpenAICapTools')}</th>
|
||||||
|
<th>{t('settings.ai.genericOpenAICapVision')}</th>
|
||||||
|
<th>{t('settings.ai.genericOpenAICapDisableThinking')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{genericOpenAIModels.map(m => {
|
||||||
|
const caps = genericOpenAICapabilities[m.id] ?? { tools: false, vision: false, disableThinking: false };
|
||||||
|
return (
|
||||||
|
<tr key={m.id}>
|
||||||
|
<td>{m.name}</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={caps.tools}
|
||||||
|
onChange={(e) => handleGenericOpenAICapabilityToggle(m.id, 'tools', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={caps.vision}
|
||||||
|
onChange={(e) => handleGenericOpenAICapabilityToggle(m.id, 'vision', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={caps.disableThinking}
|
||||||
|
onChange={(e) => handleGenericOpenAICapabilityToggle(m.id, 'disableThinking', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
<SettingRow
|
<SettingRow
|
||||||
id="ai-offline"
|
id="ai-offline"
|
||||||
label={t('settings.ai.offlineLabel')}
|
label={t('settings.ai.offlineLabel')}
|
||||||
|
|||||||
@@ -848,6 +848,7 @@
|
|||||||
"settings.ai.providerMistral": "Mistral",
|
"settings.ai.providerMistral": "Mistral",
|
||||||
"settings.ai.providerOllama": "Ollama (Lokal)",
|
"settings.ai.providerOllama": "Ollama (Lokal)",
|
||||||
"settings.ai.providerLmstudio": "LM Studio (Lokal)",
|
"settings.ai.providerLmstudio": "LM Studio (Lokal)",
|
||||||
|
"settings.ai.providerGenericOpenAI": "Generischer OpenAI-Endpunkt",
|
||||||
"settings.ai.providerOther": "Andere",
|
"settings.ai.providerOther": "Andere",
|
||||||
"settings.ai.ollamaLabel": "Ollama (Lokale Modelle)",
|
"settings.ai.ollamaLabel": "Ollama (Lokale Modelle)",
|
||||||
"settings.ai.ollamaDescription": "Verbinde dich mit einer lokal laufenden Ollama-Instanz, um lokale KI-Modelle zu verwenden.",
|
"settings.ai.ollamaDescription": "Verbinde dich mit einer lokal laufenden Ollama-Instanz, um lokale KI-Modelle zu verwenden.",
|
||||||
@@ -871,6 +872,23 @@
|
|||||||
"settings.ai.lmstudioCapVision": "Vision",
|
"settings.ai.lmstudioCapVision": "Vision",
|
||||||
"settings.toast.lmstudioEnabled": "LM Studio aktiviert",
|
"settings.toast.lmstudioEnabled": "LM Studio aktiviert",
|
||||||
"settings.toast.lmstudioDisabled": "LM Studio deaktiviert",
|
"settings.toast.lmstudioDisabled": "LM Studio deaktiviert",
|
||||||
|
"settings.ai.genericOpenAILabel": "Generischer OpenAI-kompatibler Endpunkt",
|
||||||
|
"settings.ai.genericOpenOIDescription": "Konfiguriere einen benutzerdefinierten OpenAI-kompatiblen API-Endpunkt (z.B. vLLM, Ollama-Gateway, LiteLLM). Modelle werden von diesem Endpunkt bezogen.",
|
||||||
|
"settings.ai.genericOpenAIEnable": "Generischen OpenAI-Endpunkt aktivieren",
|
||||||
|
"settings.ai.genericOpenAIBaseUrlLabel": "Basis-URL",
|
||||||
|
"settings.ai.genericOpenAIBaseUrlDescription": "Die Basis-URL des OpenAI-kompatiblen API-Endpunkts (z.B. http://localhost:8080/v1).",
|
||||||
|
"settings.ai.genericOpenAIApiKeyLabel": "API-Schlüssel",
|
||||||
|
"settings.ai.genericOpenAIApiKeyDescription": "API-Schlüssel für den Endpunkt. Leer lassen, wenn nicht erforderlich.",
|
||||||
|
"settings.ai.genericOpenAIApiKeyConfigured": "API-Schlüssel konfiguriert",
|
||||||
|
"settings.ai.genericOpenAICapabilitiesDescription": "Fähigkeiten für jedes Modell von diesem Endpunkt konfigurieren. Tools für Funktionsaufrufe oder Vision für Bildanalyse aktivieren.",
|
||||||
|
"settings.ai.genericOpenAICapModel": "Modell",
|
||||||
|
"settings.ai.genericOpenAICapTools": "Tools",
|
||||||
|
"settings.ai.genericOpenAICapVision": "Vision",
|
||||||
|
"settings.ai.genericOpenAICapDisableThinking": "Denkmodus deaktivieren",
|
||||||
|
"settings.toast.genericOpenAIEnabled": "Generischer OpenAI-Endpunkt aktiviert",
|
||||||
|
"settings.toast.genericOpenAIDisabled": "Generischer OpenAI-Endpunkt deaktiviert",
|
||||||
|
"settings.toast.genericOpenAISettingsSaved": "Generische OpenAI-Einstellungen gespeichert",
|
||||||
|
"settings.toast.genericOpenAISettingsSaveFailed": "Generische OpenAI-Einstellungen konnten nicht gespeichert werden",
|
||||||
"settings.ai.offlineLabel": "Flugmodus",
|
"settings.ai.offlineLabel": "Flugmodus",
|
||||||
"settings.ai.offlineDescription": "Wenn aktiviert, werden nur lokal gehostete Modelle (Ollama, LM Studio) verwendet. Cloud-Anbieter werden deaktiviert.",
|
"settings.ai.offlineDescription": "Wenn aktiviert, werden nur lokal gehostete Modelle (Ollama, LM Studio) verwendet. Cloud-Anbieter werden deaktiviert.",
|
||||||
"settings.ai.offlineEnable": "Flugmodus aktivieren",
|
"settings.ai.offlineEnable": "Flugmodus aktivieren",
|
||||||
|
|||||||
@@ -848,6 +848,7 @@
|
|||||||
"settings.ai.providerMistral": "Mistral",
|
"settings.ai.providerMistral": "Mistral",
|
||||||
"settings.ai.providerOllama": "Ollama (Local)",
|
"settings.ai.providerOllama": "Ollama (Local)",
|
||||||
"settings.ai.providerLmstudio": "LM Studio (Local)",
|
"settings.ai.providerLmstudio": "LM Studio (Local)",
|
||||||
|
"settings.ai.providerGenericOpenAI": "Generic OpenAI Endpoint",
|
||||||
"settings.ai.providerOther": "Other",
|
"settings.ai.providerOther": "Other",
|
||||||
"settings.ai.ollamaLabel": "Ollama (Local Models)",
|
"settings.ai.ollamaLabel": "Ollama (Local Models)",
|
||||||
"settings.ai.ollamaDescription": "Connect to a locally running Ollama instance to use local AI models.",
|
"settings.ai.ollamaDescription": "Connect to a locally running Ollama instance to use local AI models.",
|
||||||
@@ -871,6 +872,23 @@
|
|||||||
"settings.ai.lmstudioCapVision": "Vision",
|
"settings.ai.lmstudioCapVision": "Vision",
|
||||||
"settings.toast.lmstudioEnabled": "LM Studio enabled",
|
"settings.toast.lmstudioEnabled": "LM Studio enabled",
|
||||||
"settings.toast.lmstudioDisabled": "LM Studio disabled",
|
"settings.toast.lmstudioDisabled": "LM Studio disabled",
|
||||||
|
"settings.ai.genericOpenAILabel": "Generic OpenAI-Compatible Endpoint",
|
||||||
|
"settings.ai.genericOpenOIDescription": "Configure a custom OpenAI-compatible API endpoint (e.g., vLLM, Ollama gateway, LiteLLM). Models will be fetched from this endpoint.",
|
||||||
|
"settings.ai.genericOpenAIEnable": "Enable Generic OpenAI Endpoint",
|
||||||
|
"settings.ai.genericOpenAIBaseUrlLabel": "Base URL",
|
||||||
|
"settings.ai.genericOpenAIBaseUrlDescription": "The base URL of the OpenAI-compatible API endpoint (e.g., http://localhost:8080/v1).",
|
||||||
|
"settings.ai.genericOpenAIApiKeyLabel": "API Key",
|
||||||
|
"settings.ai.genericOpenAIApiKeyDescription": "API key for the endpoint. Leave empty if not required.",
|
||||||
|
"settings.ai.genericOpenAIApiKeyConfigured": "API key configured",
|
||||||
|
"settings.ai.genericOpenAICapabilitiesDescription": "Configure capabilities for each model from this endpoint. Enable tools for function calling or vision for image analysis.",
|
||||||
|
"settings.ai.genericOpenAICapModel": "Model",
|
||||||
|
"settings.ai.genericOpenAICapTools": "Tools",
|
||||||
|
"settings.ai.genericOpenAICapVision": "Vision",
|
||||||
|
"settings.ai.genericOpenAICapDisableThinking": "Disable Thinking",
|
||||||
|
"settings.toast.genericOpenAIEnabled": "Generic OpenAI endpoint enabled",
|
||||||
|
"settings.toast.genericOpenAIDisabled": "Generic OpenAI endpoint disabled",
|
||||||
|
"settings.toast.genericOpenAISettingsSaved": "Generic OpenAI settings saved",
|
||||||
|
"settings.toast.genericOpenAISettingsSaveFailed": "Failed to save generic OpenAI settings",
|
||||||
"settings.ai.offlineLabel": "Airplane Mode",
|
"settings.ai.offlineLabel": "Airplane Mode",
|
||||||
"settings.ai.offlineDescription": "When enabled, only locally hosted models (Ollama, LM Studio) are used. Cloud providers are disabled.",
|
"settings.ai.offlineDescription": "When enabled, only locally hosted models (Ollama, LM Studio) are used. Cloud providers are disabled.",
|
||||||
"settings.ai.offlineEnable": "Enable Airplane Mode",
|
"settings.ai.offlineEnable": "Enable Airplane Mode",
|
||||||
|
|||||||
@@ -848,6 +848,7 @@
|
|||||||
"settings.ai.providerMistral": "Mistral",
|
"settings.ai.providerMistral": "Mistral",
|
||||||
"settings.ai.providerOllama": "Ollama (Local)",
|
"settings.ai.providerOllama": "Ollama (Local)",
|
||||||
"settings.ai.providerLmstudio": "LM Studio (Local)",
|
"settings.ai.providerLmstudio": "LM Studio (Local)",
|
||||||
|
"settings.ai.providerGenericOpenAI": "Endpoint Genérico OpenAI",
|
||||||
"settings.ai.providerOther": "Otro",
|
"settings.ai.providerOther": "Otro",
|
||||||
"settings.ai.ollamaLabel": "Ollama (Modelos locales)",
|
"settings.ai.ollamaLabel": "Ollama (Modelos locales)",
|
||||||
"settings.ai.ollamaDescription": "Conéctate a una instancia local de Ollama para usar modelos de IA locales.",
|
"settings.ai.ollamaDescription": "Conéctate a una instancia local de Ollama para usar modelos de IA locales.",
|
||||||
@@ -871,6 +872,23 @@
|
|||||||
"settings.ai.lmstudioCapVision": "Visión",
|
"settings.ai.lmstudioCapVision": "Visión",
|
||||||
"settings.toast.lmstudioEnabled": "LM Studio activado",
|
"settings.toast.lmstudioEnabled": "LM Studio activado",
|
||||||
"settings.toast.lmstudioDisabled": "LM Studio desactivado",
|
"settings.toast.lmstudioDisabled": "LM Studio desactivado",
|
||||||
|
"settings.ai.genericOpenAILabel": "Endpoint Genérico Compatible con OpenAI",
|
||||||
|
"settings.ai.genericOpenOIDescription": "Configura un endpoint de API compatible con OpenAI personalizado (por ejemplo, vLLM, gateway Ollama, LiteLLM). Los modelos se obtendrán de este endpoint.",
|
||||||
|
"settings.ai.genericOpenAIEnable": "Habilitar Endpoint Genérico OpenAI",
|
||||||
|
"settings.ai.genericOpenAIBaseUrlLabel": "URL base",
|
||||||
|
"settings.ai.genericOpenAIBaseUrlDescription": "La URL base del endpoint de API compatible con OpenAI (por ejemplo, http://localhost:8080/v1).",
|
||||||
|
"settings.ai.genericOpenAIApiKeyLabel": "Clave API",
|
||||||
|
"settings.ai.genericOpenAIApiKeyDescription": "Clave API para el endpoint. Déjela vacía si no es necesaria.",
|
||||||
|
"settings.ai.genericOpenAIApiKeyConfigured": "Clave API configurada",
|
||||||
|
"settings.ai.genericOpenAICapabilitiesDescription": "Configure las capacidades para cada modelo de este endpoint. Habilite herramientas para llamadas de funciones o visión para análisis de imágenes.",
|
||||||
|
"settings.ai.genericOpenAICapModel": "Modelo",
|
||||||
|
"settings.ai.genericOpenAICapTools": "Herramientas",
|
||||||
|
"settings.ai.genericOpenAICapVision": "Visión",
|
||||||
|
"settings.ai.genericOpenAICapDisableThinking": "Desactivar razonamiento",
|
||||||
|
"settings.toast.genericOpenAIEnabled": "Endpoint genérico OpenAI habilitado",
|
||||||
|
"settings.toast.genericOpenAIDisabled": "Endpoint genérico OpenAI deshabilitado",
|
||||||
|
"settings.toast.genericOpenAISettingsSaved": "Configuración genérica de OpenAI guardada",
|
||||||
|
"settings.toast.genericOpenAISettingsSaveFailed": "Error al guardar la configuración genérica de OpenAI",
|
||||||
"settings.ai.offlineLabel": "Modo avión",
|
"settings.ai.offlineLabel": "Modo avión",
|
||||||
"settings.ai.offlineDescription": "Cuando está activado, solo se usan modelos alojados localmente (Ollama, LM Studio). Los proveedores en la nube se desactivan.",
|
"settings.ai.offlineDescription": "Cuando está activado, solo se usan modelos alojados localmente (Ollama, LM Studio). Los proveedores en la nube se desactivan.",
|
||||||
"settings.ai.offlineEnable": "Activar modo avión",
|
"settings.ai.offlineEnable": "Activar modo avión",
|
||||||
|
|||||||
@@ -848,6 +848,7 @@
|
|||||||
"settings.ai.providerMistral": "Mistral",
|
"settings.ai.providerMistral": "Mistral",
|
||||||
"settings.ai.providerOllama": "Ollama (Local)",
|
"settings.ai.providerOllama": "Ollama (Local)",
|
||||||
"settings.ai.providerLmstudio": "LM Studio (Local)",
|
"settings.ai.providerLmstudio": "LM Studio (Local)",
|
||||||
|
"settings.ai.providerGenericOpenAI": "Point de terminaison OpenAI générique",
|
||||||
"settings.ai.providerOther": "Autre",
|
"settings.ai.providerOther": "Autre",
|
||||||
"settings.ai.ollamaLabel": "Ollama (Modèles locaux)",
|
"settings.ai.ollamaLabel": "Ollama (Modèles locaux)",
|
||||||
"settings.ai.ollamaDescription": "Connectez-vous à une instance Ollama locale pour utiliser des modèles d'IA locaux.",
|
"settings.ai.ollamaDescription": "Connectez-vous à une instance Ollama locale pour utiliser des modèles d'IA locaux.",
|
||||||
@@ -871,6 +872,23 @@
|
|||||||
"settings.ai.lmstudioCapVision": "Vision",
|
"settings.ai.lmstudioCapVision": "Vision",
|
||||||
"settings.toast.lmstudioEnabled": "LM Studio activé",
|
"settings.toast.lmstudioEnabled": "LM Studio activé",
|
||||||
"settings.toast.lmstudioDisabled": "LM Studio désactivé",
|
"settings.toast.lmstudioDisabled": "LM Studio désactivé",
|
||||||
|
"settings.ai.genericOpenAILabel": "Point de terminaison OpenAI générique",
|
||||||
|
"settings.ai.genericOpenOIDescription": "Configurez un point de terminaison d'API OpenAI compatible personnalisé (par exemple, vLLM, passerelle Ollama, LiteLLM). Les modèles seront récupérés depuis ce point de terminaison.",
|
||||||
|
"settings.ai.genericOpenAIEnable": "Activer le point de terminaison OpenAI générique",
|
||||||
|
"settings.ai.genericOpenAIBaseUrlLabel": "URL de base",
|
||||||
|
"settings.ai.genericOpenAIBaseUrlDescription": "L'URL de base du point de terminaison d'API compatible OpenAI (par exemple, http://localhost:8080/v1).",
|
||||||
|
"settings.ai.genericOpenAIApiKeyLabel": "Clé API",
|
||||||
|
"settings.ai.genericOpenAIApiKeyDescription": "Clé API pour le point de terminaison. Laissez vide si non requise.",
|
||||||
|
"settings.ai.genericOpenAIApiKeyConfigured": "Clé API configurée",
|
||||||
|
"settings.ai.genericOpenAICapabilitiesDescription": "Configurez les capacités pour chaque modèle de ce point de terminaison. Activez les outils pour l'appel de fonctions ou la vision pour l'analyse d'images.",
|
||||||
|
"settings.ai.genericOpenAICapModel": "Modèle",
|
||||||
|
"settings.ai.genericOpenAICapTools": "Outils",
|
||||||
|
"settings.ai.genericOpenAICapVision": "Vision",
|
||||||
|
"settings.ai.genericOpenAICapDisableThinking": "Désactiver le raisonnement",
|
||||||
|
"settings.toast.genericOpenAIEnabled": "Point de terminaison OpenAI générique activé",
|
||||||
|
"settings.toast.genericOpenAIDisabled": "Point de terminaison OpenAI générique désactivé",
|
||||||
|
"settings.toast.genericOpenAISettingsSaved": "Paramètres OpenAI génériques enregistrés",
|
||||||
|
"settings.toast.genericOpenAISettingsSaveFailed": "Échec de l'enregistrement des paramètres OpenAI génériques",
|
||||||
"settings.ai.offlineLabel": "Mode avion",
|
"settings.ai.offlineLabel": "Mode avion",
|
||||||
"settings.ai.offlineDescription": "Lorsqu'il est activé, seuls les modèles hébergés localement (Ollama, LM Studio) sont utilisés. Les fournisseurs cloud sont désactivés.",
|
"settings.ai.offlineDescription": "Lorsqu'il est activé, seuls les modèles hébergés localement (Ollama, LM Studio) sont utilisés. Les fournisseurs cloud sont désactivés.",
|
||||||
"settings.ai.offlineEnable": "Activer le mode avion",
|
"settings.ai.offlineEnable": "Activer le mode avion",
|
||||||
|
|||||||
@@ -848,6 +848,7 @@
|
|||||||
"settings.ai.providerMistral": "Mistral",
|
"settings.ai.providerMistral": "Mistral",
|
||||||
"settings.ai.providerOllama": "Ollama (Locale)",
|
"settings.ai.providerOllama": "Ollama (Locale)",
|
||||||
"settings.ai.providerLmstudio": "LM Studio (Locale)",
|
"settings.ai.providerLmstudio": "LM Studio (Locale)",
|
||||||
|
"settings.ai.providerGenericOpenAI": "Endpoint Generico OpenAI",
|
||||||
"settings.ai.providerOther": "Altro",
|
"settings.ai.providerOther": "Altro",
|
||||||
"settings.ai.ollamaLabel": "Ollama (Modelli locali)",
|
"settings.ai.ollamaLabel": "Ollama (Modelli locali)",
|
||||||
"settings.ai.ollamaDescription": "Connettiti a un'istanza Ollama locale per utilizzare modelli IA locali.",
|
"settings.ai.ollamaDescription": "Connettiti a un'istanza Ollama locale per utilizzare modelli IA locali.",
|
||||||
@@ -871,6 +872,23 @@
|
|||||||
"settings.ai.lmstudioCapVision": "Visione",
|
"settings.ai.lmstudioCapVision": "Visione",
|
||||||
"settings.toast.lmstudioEnabled": "LM Studio attivato",
|
"settings.toast.lmstudioEnabled": "LM Studio attivato",
|
||||||
"settings.toast.lmstudioDisabled": "LM Studio disattivato",
|
"settings.toast.lmstudioDisabled": "LM Studio disattivato",
|
||||||
|
"settings.ai.genericOpenAILabel": "Endpoint Generico Compatibile con OpenAI",
|
||||||
|
"settings.ai.genericOpenOIDescription": "Configura un endpoint API personalizzato compatibile con OpenAI (ad esempio, vLLM, gateway Ollama, LiteLLM). I modelli verranno recuperati da questo endpoint.",
|
||||||
|
"settings.ai.genericOpenAIEnable": "Abilita Endpoint Generico OpenAI",
|
||||||
|
"settings.ai.genericOpenAIBaseUrlLabel": "URL di base",
|
||||||
|
"settings.ai.genericOpenAIBaseUrlDescription": "L'URL di base dell'endpoint API compatibile con OpenAI (ad esempio, http://localhost:8080/v1).",
|
||||||
|
"settings.ai.genericOpenAIApiKeyLabel": "Chiave API",
|
||||||
|
"settings.ai.genericOpenAIApiKeyDescription": "Chiave API per l'endpoint. Lascia vuoto se non richiesta.",
|
||||||
|
"settings.ai.genericOpenAIApiKeyConfigured": "Chiave API configurata",
|
||||||
|
"settings.ai.genericOpenAICapabilitiesDescription": "Configura le capacità per ogni modello da questo endpoint. Abilita gli strumenti per le chiamate alle funzioni o la visione per l'analisi delle immagini.",
|
||||||
|
"settings.ai.genericOpenAICapModel": "Modello",
|
||||||
|
"settings.ai.genericOpenAICapTools": "Strumenti",
|
||||||
|
"settings.ai.genericOpenAICapVision": "Visione",
|
||||||
|
"settings.ai.genericOpenAICapDisableThinking": "Disattiva ragionamento",
|
||||||
|
"settings.toast.genericOpenAIEnabled": "Endpoint generico OpenAI abilitato",
|
||||||
|
"settings.toast.genericOpenAIDisabled": "Endpoint generico OpenAI disabilitato",
|
||||||
|
"settings.toast.genericOpenAISettingsSaved": "Impostazioni generiche OpenAI salvate",
|
||||||
|
"settings.toast.genericOpenAISettingsSaveFailed": "Impossibile salvare le impostazioni generiche OpenAI",
|
||||||
"settings.ai.offlineLabel": "Modalità aereo",
|
"settings.ai.offlineLabel": "Modalità aereo",
|
||||||
"settings.ai.offlineDescription": "Quando attivato, vengono utilizzati solo i modelli ospitati localmente (Ollama, LM Studio). I provider cloud sono disabilitati.",
|
"settings.ai.offlineDescription": "Quando attivato, vengono utilizzati solo i modelli ospitati localmente (Ollama, LM Studio). I provider cloud sono disabilitati.",
|
||||||
"settings.ai.offlineEnable": "Attiva modalità aereo",
|
"settings.ai.offlineEnable": "Attiva modalità aereo",
|
||||||
|
|||||||
@@ -140,15 +140,15 @@ describe('ProviderRegistry', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('getProviderStatus() reports all providers', () => {
|
it('getProviderStatus() reports all providers', () => {
|
||||||
expect(registry.getProviderStatus()).toEqual({ opencode: false, mistral: false, ollama: false, lmstudio: false, offlineMode: false });
|
expect(registry.getProviderStatus()).toEqual({ opencode: false, mistral: false, ollama: false, lmstudio: false, genericOpenAI: false, offlineMode: false });
|
||||||
registry.setOpencodeKey('test');
|
registry.setOpencodeKey('test');
|
||||||
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: false, ollama: false, lmstudio: false, offlineMode: false });
|
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: false, ollama: false, lmstudio: false, genericOpenAI: false, offlineMode: false });
|
||||||
registry.setMistralKey('test2');
|
registry.setMistralKey('test2');
|
||||||
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: false, lmstudio: false, offlineMode: false });
|
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: false, lmstudio: false, genericOpenAI: false, offlineMode: false });
|
||||||
registry.setOllamaEnabled(true);
|
registry.setOllamaEnabled(true);
|
||||||
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: true, lmstudio: false, offlineMode: false });
|
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: true, lmstudio: false, genericOpenAI: false, offlineMode: false });
|
||||||
registry.setLmstudioEnabled(true);
|
registry.setLmstudioEnabled(true);
|
||||||
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: true, lmstudio: true, offlineMode: false });
|
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: true, lmstudio: true, genericOpenAI: false, offlineMode: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('isProviderKeySet() checks per-provider', () => {
|
it('isProviderKeySet() checks per-provider', () => {
|
||||||
|
|||||||
106
tests/engine/generic-openai-chat-service.test.ts
Normal file
106
tests/engine/generic-openai-chat-service.test.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({
|
||||||
|
mockStreamText: vi.fn(),
|
||||||
|
mockGenerateText: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('ai', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('ai')>('ai');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
streamText: mockStreamText,
|
||||||
|
generateText: mockGenerateText,
|
||||||
|
stepCountIs: vi.fn(() => undefined),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/main/engine/ModelCatalogEngine', () => ({
|
||||||
|
ModelCatalogEngine: class {
|
||||||
|
getAll = vi.fn().mockResolvedValue([]);
|
||||||
|
getContextWindow = vi.fn().mockResolvedValue(8192);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ChatService } from '../../src/main/engine/ai/chat';
|
||||||
|
import { ProviderRegistry } from '../../src/main/engine/ai/providers';
|
||||||
|
|
||||||
|
function createChatEngine() {
|
||||||
|
return {
|
||||||
|
getConversation: vi.fn(async () => ({
|
||||||
|
id: 'conv-1',
|
||||||
|
title: 'Untitled',
|
||||||
|
model: 'generic-model',
|
||||||
|
createdAt: new Date(),
|
||||||
|
messages: [],
|
||||||
|
})),
|
||||||
|
addMessage: vi.fn(async () => undefined),
|
||||||
|
getDefaultSystemPrompt: vi.fn(async () => 'You are a helpful assistant'),
|
||||||
|
getSetting: vi.fn(async (key: string) => {
|
||||||
|
if (key === 'chat_title_model') {
|
||||||
|
return 'generic-model';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
updateConversation: vi.fn(async () => undefined),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChatService generic OpenAI endpoint support', () => {
|
||||||
|
let registry: ProviderRegistry;
|
||||||
|
let chatEngine: ReturnType<typeof createChatEngine>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
mockStreamText.mockResolvedValue({
|
||||||
|
response: Promise.resolve(),
|
||||||
|
usage: Promise.resolve(undefined),
|
||||||
|
text: Promise.resolve('assistant reply'),
|
||||||
|
});
|
||||||
|
mockGenerateText.mockResolvedValue({ text: 'Generic Title' });
|
||||||
|
|
||||||
|
registry = new ProviderRegistry();
|
||||||
|
registry.setGenericOpenAIEnabled(true);
|
||||||
|
registry.setGenericOpenAIBaseURL('http://localhost:4000/v1');
|
||||||
|
registry.registerGenericOpenAIModel('generic-model');
|
||||||
|
vi.spyOn(registry, 'resolveModel').mockReturnValue({ modelId: 'generic-model' } as any);
|
||||||
|
|
||||||
|
chatEngine = createChatEngine();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips tools for generic models when tools capability is disabled', async () => {
|
||||||
|
registry.setGenericOpenAIModelCapabilities('generic-model', { tools: false, vision: false });
|
||||||
|
|
||||||
|
const service = new ChatService(chatEngine, registry, {
|
||||||
|
postEngine: {} as any,
|
||||||
|
mediaEngine: {} as any,
|
||||||
|
postMediaEngine: {} as any,
|
||||||
|
}, () => null);
|
||||||
|
|
||||||
|
const result = await service.sendMessage('conv-1', 'Hello');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(mockStreamText).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
tools: undefined,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates a title with the configured generic endpoint title model', async () => {
|
||||||
|
registry.setGenericOpenAIModelCapabilities('generic-model', { tools: false, vision: false });
|
||||||
|
|
||||||
|
const service = new ChatService(chatEngine, registry, {
|
||||||
|
postEngine: {} as any,
|
||||||
|
mediaEngine: {} as any,
|
||||||
|
postMediaEngine: {} as any,
|
||||||
|
}, () => null);
|
||||||
|
|
||||||
|
await (service as any).generateConversationTitle('conv-1', 'Hello');
|
||||||
|
|
||||||
|
expect(mockGenerateText).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
model: expect.anything(),
|
||||||
|
prompt: 'Topic: Hello',
|
||||||
|
}));
|
||||||
|
expect(chatEngine.updateConversation).toHaveBeenCalledWith('conv-1', { title: 'Generic Title' });
|
||||||
|
});
|
||||||
|
});
|
||||||
112
tests/engine/generic-openai-provider.test.ts
Normal file
112
tests/engine/generic-openai-provider.test.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Tests for generic OpenAI-compatible endpoint support in ProviderRegistry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { generateText } from 'ai';
|
||||||
|
import { ProviderRegistry } from '../../src/main/engine/ai/providers';
|
||||||
|
|
||||||
|
vi.mock('../../src/main/engine/ModelCatalogEngine', () => ({
|
||||||
|
ModelCatalogEngine: class {
|
||||||
|
getAll = vi.fn().mockResolvedValue([]);
|
||||||
|
getContextWindow = vi.fn().mockResolvedValue(null);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('generic OpenAI-compatible provider support', () => {
|
||||||
|
let registry: ProviderRegistry;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registry = new ProviderRegistry();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchGenericOpenAIModels does not duplicate the v1 path when base URL already ends with /v1', async () => {
|
||||||
|
registry.setGenericOpenAIEnabled(true);
|
||||||
|
registry.setGenericOpenAIBaseURL('http://localhost:4000/v1');
|
||||||
|
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [{ id: 'custom-model' }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = mockFetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const models = await registry.fetchGenericOpenAIModels();
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
'http://localhost:4000/v1/models',
|
||||||
|
expect.objectContaining({ method: 'GET', signal: expect.any(AbortSignal) }),
|
||||||
|
);
|
||||||
|
expect(models).toHaveLength(1);
|
||||||
|
expect(models[0]).toMatchObject({ id: 'custom-model', provider: 'generic-openai' });
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat generic endpoint models as local when airplane mode is active', () => {
|
||||||
|
registry.setGenericOpenAIEnabled(true);
|
||||||
|
registry.setGenericOpenAIBaseURL('http://localhost:4000/v1');
|
||||||
|
registry.registerGenericOpenAIModel('custom-model');
|
||||||
|
registry.setOfflineMode(true);
|
||||||
|
|
||||||
|
expect(registry.isReady()).toBe(false);
|
||||||
|
expect(registry.getKnownLocalModels()).toEqual([]);
|
||||||
|
expect(() => registry.resolveModel('custom-model')).toThrow(/not available offline/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores disableThinking for generic endpoint models', () => {
|
||||||
|
registry.setGenericOpenAIModelCapabilities('custom-model', { tools: false, vision: true, disableThinking: true });
|
||||||
|
|
||||||
|
expect(registry.getGenericOpenAIModelCapabilities('custom-model')).toEqual({
|
||||||
|
tools: false,
|
||||||
|
vision: true,
|
||||||
|
disableThinking: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('injects enable_thinking false only when disableThinking is enabled', async () => {
|
||||||
|
registry.setGenericOpenAIEnabled(true);
|
||||||
|
registry.setGenericOpenAIBaseURL('http://localhost:4000/v1');
|
||||||
|
registry.registerGenericOpenAIModel('custom-model');
|
||||||
|
registry.setGenericOpenAIModelCapabilities('custom-model', { tools: false, vision: false, disableThinking: true });
|
||||||
|
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
id: 'chatcmpl-test',
|
||||||
|
object: 'chat.completion',
|
||||||
|
created: 1,
|
||||||
|
model: 'custom-model',
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Short title',
|
||||||
|
},
|
||||||
|
finish_reason: 'stop',
|
||||||
|
}],
|
||||||
|
usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 },
|
||||||
|
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = mockFetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const model = registry.resolveModel('custom-model');
|
||||||
|
const result = await generateText({
|
||||||
|
model,
|
||||||
|
prompt: 'hello',
|
||||||
|
maxOutputTokens: 10,
|
||||||
|
maxRetries: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.text).toBe('Short title');
|
||||||
|
const [, request] = mockFetch.mock.calls[0] as [string, { body: string }];
|
||||||
|
expect(JSON.parse(request.body)).toMatchObject({
|
||||||
|
chat_template_kwargs: { enable_thinking: false },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,9 +24,11 @@ const secureKeyStoreInstances: Array<Record<string, any>> = [];
|
|||||||
|
|
||||||
// Per-test overrides for SecureKeyStore mock behavior
|
// Per-test overrides for SecureKeyStore mock behavior
|
||||||
let secureKeyStoreRetrieveResult: string | null = 'encrypted-stored-key';
|
let secureKeyStoreRetrieveResult: string | null = 'encrypted-stored-key';
|
||||||
|
let secureKeyStoreRetrieveByKey = new Map<string, string | null>();
|
||||||
let secureKeyStoreStoreError: Error | null = null;
|
let secureKeyStoreStoreError: Error | null = null;
|
||||||
let secureKeyStoreRetrieveError: Error | null = null;
|
let secureKeyStoreRetrieveError: Error | null = null;
|
||||||
let secureKeyStoreCleanupError: Error | null = null;
|
let secureKeyStoreCleanupError: Error | null = null;
|
||||||
|
let chatEngineSettingValues = new Map<string, string | null>();
|
||||||
|
|
||||||
vi.mock('electron', () => ({
|
vi.mock('electron', () => ({
|
||||||
BrowserWindow: {
|
BrowserWindow: {
|
||||||
@@ -55,7 +57,7 @@ vi.mock('../../src/main/engine/ChatEngine', () => ({
|
|||||||
ChatEngine: class {
|
ChatEngine: class {
|
||||||
constructor() {
|
constructor() {
|
||||||
const instance = {
|
const instance = {
|
||||||
getSetting: vi.fn(async () => null),
|
getSetting: vi.fn(async (key: string) => chatEngineSettingValues.get(key) ?? null),
|
||||||
setSetting: vi.fn(async () => undefined),
|
setSetting: vi.fn(async () => undefined),
|
||||||
deleteSetting: vi.fn(async () => undefined),
|
deleteSetting: vi.fn(async () => undefined),
|
||||||
getSelectedModel: vi.fn(async () => 'gpt-5'),
|
getSelectedModel: vi.fn(async () => 'gpt-5'),
|
||||||
@@ -75,8 +77,11 @@ vi.mock('../../src/main/engine/SecureKeyStore', () => ({
|
|||||||
store: vi.fn(async (_key: string, _value: string) => {
|
store: vi.fn(async (_key: string, _value: string) => {
|
||||||
if (secureKeyStoreStoreError) throw secureKeyStoreStoreError;
|
if (secureKeyStoreStoreError) throw secureKeyStoreStoreError;
|
||||||
}),
|
}),
|
||||||
retrieve: vi.fn(async () => {
|
retrieve: vi.fn(async (key: string) => {
|
||||||
if (secureKeyStoreRetrieveError) throw secureKeyStoreRetrieveError;
|
if (secureKeyStoreRetrieveError) throw secureKeyStoreRetrieveError;
|
||||||
|
if (secureKeyStoreRetrieveByKey.has(key)) {
|
||||||
|
return secureKeyStoreRetrieveByKey.get(key) ?? null;
|
||||||
|
}
|
||||||
return secureKeyStoreRetrieveResult;
|
return secureKeyStoreRetrieveResult;
|
||||||
}),
|
}),
|
||||||
remove: vi.fn(async () => undefined),
|
remove: vi.fn(async () => undefined),
|
||||||
@@ -98,9 +103,17 @@ vi.mock('../../src/main/engine/ai/providers', () => ({
|
|||||||
getOpencodeKey: vi.fn(() => 'abc12345'),
|
getOpencodeKey: vi.fn(() => 'abc12345'),
|
||||||
setMistralKey: vi.fn(),
|
setMistralKey: vi.fn(),
|
||||||
getMistralKey: vi.fn(() => ''),
|
getMistralKey: vi.fn(() => ''),
|
||||||
|
setGenericOpenAIEnabled: vi.fn(),
|
||||||
|
isGenericOpenAIEnabled: vi.fn(() => false),
|
||||||
|
setGenericOpenAIBaseURL: vi.fn(),
|
||||||
|
getGenericOpenAIBaseURL: vi.fn(() => ''),
|
||||||
|
setGenericOpenAIApiKey: vi.fn(),
|
||||||
|
getGenericOpenAIApiKey: vi.fn(() => ''),
|
||||||
|
loadGenericOpenAIModelCapabilities: vi.fn(),
|
||||||
|
registerGenericOpenAIModel: vi.fn(),
|
||||||
isReady: vi.fn(() => true),
|
isReady: vi.fn(() => true),
|
||||||
isProviderKeySet: vi.fn(() => true),
|
isProviderKeySet: vi.fn(() => true),
|
||||||
getProviderStatus: vi.fn(() => ({ opencode: true, mistral: false })),
|
getProviderStatus: vi.fn(() => ({ opencode: true, mistral: false, ollama: false, lmstudio: false, genericOpenAI: false, offlineMode: false })),
|
||||||
resolveModel: vi.fn(),
|
resolveModel: vi.fn(),
|
||||||
getAvailableModels: vi.fn(async () => []),
|
getAvailableModels: vi.fn(async () => []),
|
||||||
validateOpencodeKey: vi.fn(async () => ({ isValid: true, models: [] })),
|
validateOpencodeKey: vi.fn(async () => ({ isValid: true, models: [] })),
|
||||||
@@ -141,9 +154,11 @@ describe('chatHandlers keychain integration', () => {
|
|||||||
providerRegistryInstances.length = 0;
|
providerRegistryInstances.length = 0;
|
||||||
secureKeyStoreInstances.length = 0;
|
secureKeyStoreInstances.length = 0;
|
||||||
secureKeyStoreRetrieveResult = 'encrypted-stored-key';
|
secureKeyStoreRetrieveResult = 'encrypted-stored-key';
|
||||||
|
secureKeyStoreRetrieveByKey = new Map();
|
||||||
secureKeyStoreStoreError = null;
|
secureKeyStoreStoreError = null;
|
||||||
secureKeyStoreRetrieveError = null;
|
secureKeyStoreRetrieveError = null;
|
||||||
secureKeyStoreCleanupError = null;
|
secureKeyStoreCleanupError = null;
|
||||||
|
chatEngineSettingValues = new Map();
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -282,6 +297,42 @@ describe('chatHandlers keychain integration', () => {
|
|||||||
expect(registry.setOpencodeKey).toHaveBeenCalledWith('encrypted-stored-key');
|
expect(registry.setOpencodeKey).toHaveBeenCalledWith('encrypted-stored-key');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('restores generic endpoint settings from storage on init', async () => {
|
||||||
|
chatEngineSettingValues = new Map([
|
||||||
|
['generic_openai_enabled', 'true'],
|
||||||
|
['generic_openai_base_url', 'http://localhost:4000/v1'],
|
||||||
|
['generic_openai_model_capabilities', JSON.stringify({
|
||||||
|
'custom-model': { tools: true, vision: false },
|
||||||
|
})],
|
||||||
|
['generic_openai_known_model_ids', JSON.stringify(['custom-model'])],
|
||||||
|
]);
|
||||||
|
secureKeyStoreRetrieveByKey = new Map([
|
||||||
|
['opencode_api_key', 'encrypted-stored-key'],
|
||||||
|
['mistral_api_key', null],
|
||||||
|
['generic_openai_api_key', 'generic-secret'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mod = await import('../../src/main/ipc/chatHandlers');
|
||||||
|
const mockBundle = { postEngine: {}, mediaEngine: {}, postMediaEngine: {} };
|
||||||
|
mod.initializeChatHandlers(() => mainWindowMock as never, mockBundle as any);
|
||||||
|
mod.registerChatHandlers();
|
||||||
|
|
||||||
|
const handler = registeredHandlers.get('chat:checkReady');
|
||||||
|
await handler!(undefined);
|
||||||
|
|
||||||
|
const registry = providerRegistryInstances[0];
|
||||||
|
expect(registry.setGenericOpenAIEnabled).toHaveBeenCalledWith(true);
|
||||||
|
expect(registry.setGenericOpenAIBaseURL).toHaveBeenCalledWith('http://localhost:4000/v1');
|
||||||
|
expect(registry.setGenericOpenAIApiKey).toHaveBeenCalledWith('generic-secret');
|
||||||
|
expect(registry.loadGenericOpenAIModelCapabilities).toHaveBeenCalledWith({
|
||||||
|
'custom-model': { tools: true, vision: false },
|
||||||
|
});
|
||||||
|
expect(registry.registerGenericOpenAIModel).toHaveBeenCalledWith('custom-model');
|
||||||
|
|
||||||
|
const keyStore = secureKeyStoreInstances[0];
|
||||||
|
expect(keyStore.retrieve).toHaveBeenCalledWith('generic_openai_api_key');
|
||||||
|
});
|
||||||
|
|
||||||
it('returns error and rolls back in-memory key when store() throws on chat:setApiKey', async () => {
|
it('returns error and rolls back in-memory key when store() throws on chat:setApiKey', async () => {
|
||||||
secureKeyStoreStoreError = new Error('encryption unavailable');
|
secureKeyStoreStoreError = new Error('encryption unavailable');
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react';
|
||||||
import { SettingsView } from '../../../src/renderer/components/SettingsView/SettingsView';
|
import { SettingsView } from '../../../src/renderer/components/SettingsView/SettingsView';
|
||||||
import { useAppStore } from '../../../src/renderer/store';
|
import { useAppStore } from '../../../src/renderer/store';
|
||||||
|
|
||||||
@@ -24,6 +24,8 @@ describe('MCPAgentButton uninstall', () => {
|
|||||||
app: { getDefaultProjectPath: vi.fn().mockResolvedValue('/repo') },
|
app: { getDefaultProjectPath: vi.fn().mockResolvedValue('/repo') },
|
||||||
meta: {
|
meta: {
|
||||||
getCategories: vi.fn().mockResolvedValue(['article']),
|
getCategories: vi.fn().mockResolvedValue(['article']),
|
||||||
|
getPublishingPreferences: vi.fn().mockResolvedValue(null),
|
||||||
|
setPublishingPreferences: vi.fn().mockResolvedValue({}),
|
||||||
getProjectMetadata: vi.fn().mockResolvedValue({
|
getProjectMetadata: vi.fn().mockResolvedValue({
|
||||||
maxPostsPerPage: 75,
|
maxPostsPerPage: 75,
|
||||||
publicUrl: 'https://example.com',
|
publicUrl: 'https://example.com',
|
||||||
@@ -36,6 +38,17 @@ describe('MCPAgentButton uninstall', () => {
|
|||||||
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
||||||
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
|
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
|
||||||
getMistralApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
getMistralApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
||||||
|
getOllamaEnabled: vi.fn().mockResolvedValue(false),
|
||||||
|
getLmstudioEnabled: vi.fn().mockResolvedValue(false),
|
||||||
|
getGenericOpenAIEnabled: vi.fn().mockResolvedValue(false),
|
||||||
|
getGenericOpenAIBaseURL: vi.fn().mockResolvedValue(''),
|
||||||
|
getGenericOpenAIApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
||||||
|
getGenericOpenAIModelCapabilities: vi.fn().mockResolvedValue({}),
|
||||||
|
getGenericOpenAIModels: vi.fn().mockResolvedValue([]),
|
||||||
|
getOfflineMode: vi.fn().mockResolvedValue(false),
|
||||||
|
getOfflineChatModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
|
||||||
|
getOfflineTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
|
||||||
|
getOfflineImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
|
||||||
getTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-haiku-4-5' }),
|
getTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-haiku-4-5' }),
|
||||||
getImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-sonnet-4-5' }),
|
getImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-sonnet-4-5' }),
|
||||||
getModelCatalog: vi.fn().mockResolvedValue({ success: true, entries: [] }),
|
getModelCatalog: vi.fn().mockResolvedValue({ success: true, entries: [] }),
|
||||||
@@ -114,6 +127,8 @@ describe('SettingsView Diff Preferences', () => {
|
|||||||
meta: {
|
meta: {
|
||||||
...(window as any).electronAPI?.meta,
|
...(window as any).electronAPI?.meta,
|
||||||
getCategories: vi.fn().mockResolvedValue(['article', 'picture', 'aside', 'page']),
|
getCategories: vi.fn().mockResolvedValue(['article', 'picture', 'aside', 'page']),
|
||||||
|
getPublishingPreferences: vi.fn().mockResolvedValue(null),
|
||||||
|
setPublishingPreferences: vi.fn().mockResolvedValue({}),
|
||||||
getProjectMetadata: vi.fn().mockResolvedValue({
|
getProjectMetadata: vi.fn().mockResolvedValue({
|
||||||
maxPostsPerPage: 75,
|
maxPostsPerPage: 75,
|
||||||
publicUrl: 'https://example.com',
|
publicUrl: 'https://example.com',
|
||||||
@@ -131,6 +146,17 @@ describe('SettingsView Diff Preferences', () => {
|
|||||||
getSystemPrompt: vi.fn().mockResolvedValue({ success: true, prompt: '' }),
|
getSystemPrompt: vi.fn().mockResolvedValue({ success: true, prompt: '' }),
|
||||||
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
||||||
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
|
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
|
||||||
|
getOllamaEnabled: vi.fn().mockResolvedValue(false),
|
||||||
|
getLmstudioEnabled: vi.fn().mockResolvedValue(false),
|
||||||
|
getGenericOpenAIEnabled: vi.fn().mockResolvedValue(false),
|
||||||
|
getGenericOpenAIBaseURL: vi.fn().mockResolvedValue(''),
|
||||||
|
getGenericOpenAIApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
||||||
|
getGenericOpenAIModelCapabilities: vi.fn().mockResolvedValue({}),
|
||||||
|
getGenericOpenAIModels: vi.fn().mockResolvedValue([]),
|
||||||
|
getOfflineMode: vi.fn().mockResolvedValue(false),
|
||||||
|
getOfflineChatModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
|
||||||
|
getOfflineTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
|
||||||
|
getOfflineImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
|
||||||
},
|
},
|
||||||
templates: {
|
templates: {
|
||||||
...(window as any).electronAPI?.templates,
|
...(window as any).electronAPI?.templates,
|
||||||
@@ -395,3 +421,101 @@ describe('SettingsView Diff Preferences', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('SettingsView generic endpoint refresh', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useAppStore.setState({
|
||||||
|
activeProject: {
|
||||||
|
id: 'project-1',
|
||||||
|
name: 'Test Project',
|
||||||
|
slug: 'test-project',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
gitDiffPreferences: {
|
||||||
|
wordWrap: true,
|
||||||
|
viewStyle: 'inline',
|
||||||
|
hideUnchangedRegions: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(window as any).electronAPI = {
|
||||||
|
...(window as any).electronAPI,
|
||||||
|
app: {
|
||||||
|
...(window as any).electronAPI?.app,
|
||||||
|
getDefaultProjectPath: vi.fn().mockResolvedValue('/repo/path'),
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
...(window as any).electronAPI?.meta,
|
||||||
|
getCategories: vi.fn().mockResolvedValue(['article']),
|
||||||
|
getPublishingPreferences: vi.fn().mockResolvedValue(null),
|
||||||
|
setPublishingPreferences: vi.fn().mockResolvedValue({}),
|
||||||
|
getProjectMetadata: vi.fn().mockResolvedValue({
|
||||||
|
maxPostsPerPage: 75,
|
||||||
|
publicUrl: 'https://example.com',
|
||||||
|
categorySettings: { article: { renderInLists: true, showTitle: true } },
|
||||||
|
}),
|
||||||
|
updateProjectMetadata: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
chat: {
|
||||||
|
...(window as any).electronAPI?.chat,
|
||||||
|
getSystemPrompt: vi.fn().mockResolvedValue({ success: true, prompt: '' }),
|
||||||
|
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
||||||
|
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
|
||||||
|
getMistralApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
||||||
|
getTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
|
||||||
|
getImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
|
||||||
|
getModelCatalog: vi.fn().mockResolvedValue({ success: true, entries: [] }),
|
||||||
|
getOllamaEnabled: vi.fn().mockResolvedValue(false),
|
||||||
|
getLmstudioEnabled: vi.fn().mockResolvedValue(false),
|
||||||
|
getGenericOpenAIEnabled: vi.fn().mockResolvedValue(true),
|
||||||
|
getGenericOpenAIBaseURL: vi.fn().mockResolvedValue('http://localhost:4000/v1'),
|
||||||
|
getGenericOpenAIApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
||||||
|
getGenericOpenAIModelCapabilities: vi.fn().mockResolvedValue({}),
|
||||||
|
getGenericOpenAIModels: vi.fn()
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce([{ id: 'generic-model', name: 'Generic Model' }]),
|
||||||
|
setGenericOpenAIBaseURL: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
getOfflineMode: vi.fn().mockResolvedValue(false),
|
||||||
|
getOfflineChatModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
|
||||||
|
getOfflineTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
|
||||||
|
getOfflineImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
|
||||||
|
},
|
||||||
|
templates: {
|
||||||
|
...(window as any).electronAPI?.templates,
|
||||||
|
getEnabledByKind: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
projects: {
|
||||||
|
...(window as any).electronAPI?.projects,
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
mcp: {
|
||||||
|
...(window as any).electronAPI?.mcp,
|
||||||
|
getAgents: vi.fn().mockResolvedValue([]),
|
||||||
|
isConfigured: vi.fn().mockResolvedValue(false),
|
||||||
|
getPort: vi.fn().mockResolvedValue(4124),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reloads generic models after saving the generic endpoint base URL', async () => {
|
||||||
|
render(<SettingsView />);
|
||||||
|
|
||||||
|
const baseUrlInput = await screen.findByLabelText(/base url/i);
|
||||||
|
const field = baseUrlInput.closest('.setting-field');
|
||||||
|
expect(field).not.toBeNull();
|
||||||
|
|
||||||
|
const saveButton = within(field as HTMLElement).getByRole('button', { name: /save/i });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect((window as any).electronAPI.chat.getAvailableModels).toHaveBeenCalledTimes(2);
|
||||||
|
expect((window as any).electronAPI.chat.getGenericOpenAIModels).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user