Compare commits
10 Commits
8ea88b67ec
...
60c8e935cf
| 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",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "blogging-desktop-server",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.50",
|
||||
@@ -32,15 +32,15 @@
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@picocss/pico": "^2.1.1",
|
||||
"@xmldom/xmldom": "^0.8.11",
|
||||
"@xmldom/xmldom": "^0.8.12",
|
||||
"ai": "^6.0.105",
|
||||
"chokidar": "^5.0.0",
|
||||
"d3-cloud": "^1.2.8",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"fast-xml-parser": "^5.5.6",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fast-xml-parser": "^5.5.7",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lightbox2": "^2.11.5",
|
||||
"liquidjs": "^10.25.0",
|
||||
"liquidjs": "^10.25.5",
|
||||
"marked-react": "^3.0.2",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"node-scp": "^0.0.25",
|
||||
@@ -53,7 +53,7 @@
|
||||
"rsyncwrapper": "^3.1.0",
|
||||
"sharp": "^0.34.5",
|
||||
"simple-git": "^3.32.3",
|
||||
"smol-toml": "^1.6.0",
|
||||
"smol-toml": "^1.6.1",
|
||||
"snowball-stemmers": "^0.6.0",
|
||||
"transliteration": "^2.6.1",
|
||||
"turndown": "^7.2.2",
|
||||
@@ -80,7 +80,7 @@
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"electron": "^40.4.0",
|
||||
"electron": "^40.8.5",
|
||||
"electron-builder": "^26.7.0",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-plugin-i18next": "^6.1.3",
|
||||
@@ -88,7 +88,7 @@
|
||||
"png-to-ico": "^3.0.1",
|
||||
"tsx": "^4.6.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite": "^7.3.2",
|
||||
"vitest": "^4.0.18",
|
||||
"wait-on": "^9.0.3"
|
||||
}
|
||||
@@ -2769,9 +2769,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.10",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.10.tgz",
|
||||
"integrity": "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==",
|
||||
"version": "1.19.13",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz",
|
||||
"integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
@@ -6148,9 +6148,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.11",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
||||
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
|
||||
"version": "0.8.12",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
|
||||
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -6719,15 +6719,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
|
||||
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bail": {
|
||||
@@ -8177,9 +8177,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/drizzle-orm": {
|
||||
"version": "0.45.1",
|
||||
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz",
|
||||
"integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==",
|
||||
"version": "0.45.2",
|
||||
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz",
|
||||
"integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"@aws-sdk/client-rds-data": ">=3",
|
||||
@@ -8345,9 +8345,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "40.4.0",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-40.4.0.tgz",
|
||||
"integrity": "sha512-31l4V7Ys4oUuXyaN/cCNnyBdDXN9RwOVOG+JhiHCf4zx5tZkHd43PKGY6KLEWpeYCxaphsuGSEjagJLfPqKj8g==",
|
||||
"version": "40.8.5",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-40.8.5.tgz",
|
||||
"integrity": "sha512-pgTY/VPQKaiU4sTjfU96iyxCXrFm4htVPCMRT4b7q9ijNTRgtLmLvcmzp2G4e7xDrq9p7OLHSmu1rBKFf6Y1/A==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -9223,9 +9223,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.5.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz",
|
||||
"integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==",
|
||||
"version": "5.5.7",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.7.tgz",
|
||||
"integrity": "sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -9236,7 +9236,7 @@
|
||||
"dependencies": {
|
||||
"fast-xml-builder": "^1.1.4",
|
||||
"path-expression-matcher": "^1.1.3",
|
||||
"strnum": "^2.1.2"
|
||||
"strnum": "^2.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
@@ -9411,16 +9411,16 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -9947,9 +9947,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.7",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
|
||||
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
|
||||
"version": "4.12.14",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
|
||||
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -10698,9 +10698,9 @@
|
||||
"integrity": "sha512-IsDqv/D9pjgh7GvwTNvmHF98+nrIcOD17fraXgtx8ivq469y95l5ycLi6SeZAZHdeyD3cGLjYwbDX8SRfWx5fA=="
|
||||
},
|
||||
"node_modules/liquidjs": {
|
||||
"version": "10.25.0",
|
||||
"resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.25.0.tgz",
|
||||
"integrity": "sha512-XpO7AiGULTG4xcTlwkcTI5JreFG7b6esLCLp+aUSh7YuQErJZEoUXre9u9rbdb0057pfWG4l0VursvLd5Q/eAw==",
|
||||
"version": "10.25.5",
|
||||
"resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.25.5.tgz",
|
||||
"integrity": "sha512-GKiKeZjJDdVoQAu+S9rzkYsYnYhcep5W3WwZXgb5f+yq484P/k9JqamBbGYu+LBEixcUAXZr2jogdAIjB3ki1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^10.0.0"
|
||||
@@ -10743,16 +10743,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
|
||||
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
@@ -12731,9 +12731,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
||||
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.1.tgz",
|
||||
"integrity": "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -12776,9 +12776,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -13224,9 +13224,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
||||
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
|
||||
"version": "7.5.5",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz",
|
||||
"integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -13261,11 +13261,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.3",
|
||||
@@ -14342,9 +14345,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/smol-toml": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz",
|
||||
"integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==",
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz",
|
||||
"integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
@@ -14598,9 +14601,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
||||
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.1.tgz",
|
||||
"integrity": "sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -15851,9 +15854,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
16
package.json
16
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "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",
|
||||
"main": "dist/main/main.js",
|
||||
"scripts": {
|
||||
@@ -54,7 +54,7 @@
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"electron": "^40.4.0",
|
||||
"electron": "^40.8.5",
|
||||
"electron-builder": "^26.7.0",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-plugin-i18next": "^6.1.3",
|
||||
@@ -62,7 +62,7 @@
|
||||
"png-to-ico": "^3.0.1",
|
||||
"tsx": "^4.6.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite": "^7.3.2",
|
||||
"vitest": "^4.0.18",
|
||||
"wait-on": "^9.0.3"
|
||||
},
|
||||
@@ -90,15 +90,15 @@
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@picocss/pico": "^2.1.1",
|
||||
"@xmldom/xmldom": "^0.8.11",
|
||||
"@xmldom/xmldom": "^0.8.12",
|
||||
"ai": "^6.0.105",
|
||||
"chokidar": "^5.0.0",
|
||||
"d3-cloud": "^1.2.8",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"fast-xml-parser": "^5.5.6",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fast-xml-parser": "^5.5.7",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lightbox2": "^2.11.5",
|
||||
"liquidjs": "^10.25.0",
|
||||
"liquidjs": "^10.25.5",
|
||||
"marked-react": "^3.0.2",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"node-scp": "^0.0.25",
|
||||
@@ -111,7 +111,7 @@
|
||||
"rsyncwrapper": "^3.1.0",
|
||||
"sharp": "^0.34.5",
|
||||
"simple-git": "^3.32.3",
|
||||
"smol-toml": "^1.6.0",
|
||||
"smol-toml": "^1.6.1",
|
||||
"snowball-stemmers": "^0.6.0",
|
||||
"transliteration": "^2.6.1",
|
||||
"turndown": "^7.2.2",
|
||||
|
||||
@@ -294,8 +294,10 @@ export class ChatService {
|
||||
// Build tools (skip for Ollama/LM Studio models unless capability override is set)
|
||||
const isOllama = this.providers.isOllamaModel(modelId);
|
||||
const isLmstudio = this.providers.isLmstudioModel(modelId);
|
||||
const isGenericOpenAI = this.providers.isGenericOpenAIModel(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 a2uiToolsRaw = skipTools ? {} : createA2UITools();
|
||||
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 LMSTUDIO_BASE_URL = 'http://localhost:1234/v1';
|
||||
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 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 GENERIC_OPENAI_FETCH_TIMEOUT = 10000; // 10 s for generic endpoints
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gateway factory
|
||||
@@ -123,6 +125,12 @@ export class ProviderRegistry {
|
||||
private lmstudioProvider: ReturnType<typeof createOpenAI> | null = null;
|
||||
private lmstudioModelIds = new Set<string>();
|
||||
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 _offlineMode = false;
|
||||
|
||||
@@ -297,9 +305,107 @@ export class ProviderRegistry {
|
||||
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
|
||||
* registration first, then falling back to prefix-based detection.
|
||||
* Detect the effective provider for a model ID, checking Ollama, LM Studio,
|
||||
* and generic OpenAI registration first, then falling back to prefix-based detection.
|
||||
*/
|
||||
detectModelProvider(modelId: string): string {
|
||||
if (this.ollamaModelIds.has(modelId)) {
|
||||
@@ -308,6 +414,9 @@ export class ProviderRegistry {
|
||||
if (this.lmstudioModelIds.has(modelId)) {
|
||||
return 'lmstudio';
|
||||
}
|
||||
if (this.genericOpenAIModelIds.has(modelId)) {
|
||||
return 'generic-openai';
|
||||
}
|
||||
return detectProvider(modelId);
|
||||
}
|
||||
|
||||
@@ -316,7 +425,7 @@ export class ProviderRegistry {
|
||||
if (this._offlineMode) {
|
||||
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. */
|
||||
@@ -327,6 +436,9 @@ export class ProviderRegistry {
|
||||
if (provider === 'lmstudio') {
|
||||
return this.lmstudioEnabled;
|
||||
}
|
||||
if (provider === 'generic-openai') {
|
||||
return this.genericOpenAIEnabled && Boolean(this.genericOpenAIBaseURL);
|
||||
}
|
||||
// In offline mode, cloud providers are unavailable
|
||||
if (this._offlineMode) {
|
||||
return false;
|
||||
@@ -338,12 +450,13 @@ export class ProviderRegistry {
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
opencode: !!this.opencodeKey,
|
||||
mistral: !!this.mistralKey,
|
||||
ollama: this.ollamaEnabled,
|
||||
lmstudio: this.lmstudioEnabled,
|
||||
genericOpenAI: this.genericOpenAIEnabled,
|
||||
offlineMode: this._offlineMode,
|
||||
};
|
||||
}
|
||||
@@ -385,6 +498,52 @@ export class ProviderRegistry {
|
||||
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);
|
||||
|
||||
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) {
|
||||
this.cachedModels = allModels;
|
||||
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 async fetchModelsFromEndpoint(
|
||||
@@ -725,6 +969,14 @@ export class ProviderRegistry {
|
||||
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[]> {
|
||||
try {
|
||||
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");
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -132,6 +132,13 @@ async function ensureInitialized(): Promise<void> {
|
||||
}
|
||||
} 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
|
||||
try {
|
||||
const ollamaEnabled = await getChatEngine().getSetting('ollama_enabled');
|
||||
@@ -167,6 +174,21 @@ async function ensureInitialized(): Promise<void> {
|
||||
}
|
||||
} 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
|
||||
try {
|
||||
const lmCapsJson = await getChatEngine().getSetting('lmstudio_model_capabilities');
|
||||
@@ -176,6 +198,15 @@ async function ensureInitialized(): Promise<void> {
|
||||
}
|
||||
} 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)
|
||||
try {
|
||||
const lmIds = await getChatEngine().getSetting('lmstudio_known_model_ids');
|
||||
@@ -186,6 +217,16 @@ async function ensureInitialized(): Promise<void> {
|
||||
}
|
||||
} 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
|
||||
try {
|
||||
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 ============
|
||||
|
||||
ipcMain.handle('chat:getOfflineMode', async () => {
|
||||
@@ -627,12 +809,16 @@ export function registerChatHandlers(): void {
|
||||
// Persist known local model IDs so offline mode survives restarts
|
||||
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 genericOpenAIModels = models.filter(m => m.provider === 'generic-openai').map(m => m.id);
|
||||
if (ollamaModels.length > 0) {
|
||||
await engine.setSetting('ollama_known_model_ids', JSON.stringify(ollamaModels)).catch(() => {});
|
||||
}
|
||||
if (lmstudioModels.length > 0) {
|
||||
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 };
|
||||
} catch (error) {
|
||||
|
||||
@@ -357,6 +357,18 @@ export const electronAPI: ElectronAPI = {
|
||||
getLmstudioModelCapabilities: () => ipcRenderer.invoke('chat:getLmstudioModelCapabilities'),
|
||||
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
|
||||
getOfflineMode: () => ipcRenderer.invoke('chat:getOfflineMode'),
|
||||
setOfflineMode: (enabled: boolean) => ipcRenderer.invoke('chat:setOfflineMode', enabled),
|
||||
|
||||
@@ -495,7 +495,7 @@ export interface ChatReadyStatus {
|
||||
ready: boolean;
|
||||
error?: 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 {
|
||||
@@ -1022,6 +1022,18 @@ export interface ElectronAPI {
|
||||
getLmstudioModelCapabilities: () => Promise<Record<string, { tools: boolean; vision: boolean }>>;
|
||||
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
|
||||
getOfflineMode: () => Promise<boolean>;
|
||||
setOfflineMode: (enabled: boolean) => Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
@@ -253,6 +253,13 @@ export const SettingsView: React.FC = () => {
|
||||
const [lmstudioEnabled, setLmstudioEnabled] = useState(false);
|
||||
const [lmstudioCapabilities, setLmstudioCapabilities] = useState<Record<string, { tools: boolean; vision: boolean }>>({});
|
||||
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 [offlineChatModel, setOfflineChatModel] = 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 })));
|
||||
}
|
||||
|
||||
// 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
|
||||
const titleModelResult = await window.electronAPI?.chat.getTitleModel();
|
||||
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) => {
|
||||
try {
|
||||
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 === 'ollama') return t('settings.ai.providerOllama');
|
||||
if (provider === 'lmstudio') return t('settings.ai.providerLmstudio');
|
||||
if (provider === 'generic-openai') return t('settings.ai.providerGenericOpenAI');
|
||||
return provider;
|
||||
};
|
||||
|
||||
@@ -1716,6 +1861,125 @@ export const SettingsView: React.FC = () => {
|
||||
)}
|
||||
</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
|
||||
id="ai-offline"
|
||||
label={t('settings.ai.offlineLabel')}
|
||||
|
||||
@@ -848,6 +848,7 @@
|
||||
"settings.ai.providerMistral": "Mistral",
|
||||
"settings.ai.providerOllama": "Ollama (Lokal)",
|
||||
"settings.ai.providerLmstudio": "LM Studio (Lokal)",
|
||||
"settings.ai.providerGenericOpenAI": "Generischer OpenAI-Endpunkt",
|
||||
"settings.ai.providerOther": "Andere",
|
||||
"settings.ai.ollamaLabel": "Ollama (Lokale Modelle)",
|
||||
"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.toast.lmstudioEnabled": "LM Studio aktiviert",
|
||||
"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.offlineDescription": "Wenn aktiviert, werden nur lokal gehostete Modelle (Ollama, LM Studio) verwendet. Cloud-Anbieter werden deaktiviert.",
|
||||
"settings.ai.offlineEnable": "Flugmodus aktivieren",
|
||||
|
||||
@@ -848,6 +848,7 @@
|
||||
"settings.ai.providerMistral": "Mistral",
|
||||
"settings.ai.providerOllama": "Ollama (Local)",
|
||||
"settings.ai.providerLmstudio": "LM Studio (Local)",
|
||||
"settings.ai.providerGenericOpenAI": "Generic OpenAI Endpoint",
|
||||
"settings.ai.providerOther": "Other",
|
||||
"settings.ai.ollamaLabel": "Ollama (Local 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.toast.lmstudioEnabled": "LM Studio enabled",
|
||||
"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.offlineDescription": "When enabled, only locally hosted models (Ollama, LM Studio) are used. Cloud providers are disabled.",
|
||||
"settings.ai.offlineEnable": "Enable Airplane Mode",
|
||||
|
||||
@@ -848,6 +848,7 @@
|
||||
"settings.ai.providerMistral": "Mistral",
|
||||
"settings.ai.providerOllama": "Ollama (Local)",
|
||||
"settings.ai.providerLmstudio": "LM Studio (Local)",
|
||||
"settings.ai.providerGenericOpenAI": "Endpoint Genérico OpenAI",
|
||||
"settings.ai.providerOther": "Otro",
|
||||
"settings.ai.ollamaLabel": "Ollama (Modelos 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.toast.lmstudioEnabled": "LM Studio activado",
|
||||
"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.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",
|
||||
|
||||
@@ -848,6 +848,7 @@
|
||||
"settings.ai.providerMistral": "Mistral",
|
||||
"settings.ai.providerOllama": "Ollama (Local)",
|
||||
"settings.ai.providerLmstudio": "LM Studio (Local)",
|
||||
"settings.ai.providerGenericOpenAI": "Point de terminaison OpenAI générique",
|
||||
"settings.ai.providerOther": "Autre",
|
||||
"settings.ai.ollamaLabel": "Ollama (Modèles 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.toast.lmstudioEnabled": "LM Studio activé",
|
||||
"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.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",
|
||||
|
||||
@@ -848,6 +848,7 @@
|
||||
"settings.ai.providerMistral": "Mistral",
|
||||
"settings.ai.providerOllama": "Ollama (Locale)",
|
||||
"settings.ai.providerLmstudio": "LM Studio (Locale)",
|
||||
"settings.ai.providerGenericOpenAI": "Endpoint Generico OpenAI",
|
||||
"settings.ai.providerOther": "Altro",
|
||||
"settings.ai.ollamaLabel": "Ollama (Modelli locali)",
|
||||
"settings.ai.ollamaDescription": "Connettiti a un'istanza Ollama locale per utilizzare modelli IA locali.",
|
||||
@@ -871,6 +872,23 @@
|
||||
"settings.ai.lmstudioCapVision": "Visione",
|
||||
"settings.toast.lmstudioEnabled": "LM Studio attivato",
|
||||
"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.offlineDescription": "Quando attivato, vengono utilizzati solo i modelli ospitati localmente (Ollama, LM Studio). I provider cloud sono disabilitati.",
|
||||
"settings.ai.offlineEnable": "Attiva modalità aereo",
|
||||
|
||||
@@ -140,15 +140,15 @@ describe('ProviderRegistry', () => {
|
||||
});
|
||||
|
||||
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');
|
||||
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');
|
||||
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);
|
||||
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);
|
||||
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', () => {
|
||||
|
||||
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
|
||||
let secureKeyStoreRetrieveResult: string | null = 'encrypted-stored-key';
|
||||
let secureKeyStoreRetrieveByKey = new Map<string, string | null>();
|
||||
let secureKeyStoreStoreError: Error | null = null;
|
||||
let secureKeyStoreRetrieveError: Error | null = null;
|
||||
let secureKeyStoreCleanupError: Error | null = null;
|
||||
let chatEngineSettingValues = new Map<string, string | null>();
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: {
|
||||
@@ -55,7 +57,7 @@ vi.mock('../../src/main/engine/ChatEngine', () => ({
|
||||
ChatEngine: class {
|
||||
constructor() {
|
||||
const instance = {
|
||||
getSetting: vi.fn(async () => null),
|
||||
getSetting: vi.fn(async (key: string) => chatEngineSettingValues.get(key) ?? null),
|
||||
setSetting: vi.fn(async () => undefined),
|
||||
deleteSetting: vi.fn(async () => undefined),
|
||||
getSelectedModel: vi.fn(async () => 'gpt-5'),
|
||||
@@ -75,8 +77,11 @@ vi.mock('../../src/main/engine/SecureKeyStore', () => ({
|
||||
store: vi.fn(async (_key: string, _value: string) => {
|
||||
if (secureKeyStoreStoreError) throw secureKeyStoreStoreError;
|
||||
}),
|
||||
retrieve: vi.fn(async () => {
|
||||
retrieve: vi.fn(async (key: string) => {
|
||||
if (secureKeyStoreRetrieveError) throw secureKeyStoreRetrieveError;
|
||||
if (secureKeyStoreRetrieveByKey.has(key)) {
|
||||
return secureKeyStoreRetrieveByKey.get(key) ?? null;
|
||||
}
|
||||
return secureKeyStoreRetrieveResult;
|
||||
}),
|
||||
remove: vi.fn(async () => undefined),
|
||||
@@ -98,9 +103,17 @@ vi.mock('../../src/main/engine/ai/providers', () => ({
|
||||
getOpencodeKey: vi.fn(() => 'abc12345'),
|
||||
setMistralKey: 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),
|
||||
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(),
|
||||
getAvailableModels: vi.fn(async () => []),
|
||||
validateOpencodeKey: vi.fn(async () => ({ isValid: true, models: [] })),
|
||||
@@ -141,9 +154,11 @@ describe('chatHandlers keychain integration', () => {
|
||||
providerRegistryInstances.length = 0;
|
||||
secureKeyStoreInstances.length = 0;
|
||||
secureKeyStoreRetrieveResult = 'encrypted-stored-key';
|
||||
secureKeyStoreRetrieveByKey = new Map();
|
||||
secureKeyStoreStoreError = null;
|
||||
secureKeyStoreRetrieveError = null;
|
||||
secureKeyStoreCleanupError = null;
|
||||
chatEngineSettingValues = new Map();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
@@ -282,6 +297,42 @@ describe('chatHandlers keychain integration', () => {
|
||||
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 () => {
|
||||
secureKeyStoreStoreError = new Error('encryption unavailable');
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
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 { useAppStore } from '../../../src/renderer/store';
|
||||
|
||||
@@ -24,6 +24,8 @@ describe('MCPAgentButton uninstall', () => {
|
||||
app: { getDefaultProjectPath: vi.fn().mockResolvedValue('/repo') },
|
||||
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',
|
||||
@@ -36,6 +38,17 @@ describe('MCPAgentButton uninstall', () => {
|
||||
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
||||
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
|
||||
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' }),
|
||||
getImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-sonnet-4-5' }),
|
||||
getModelCatalog: vi.fn().mockResolvedValue({ success: true, entries: [] }),
|
||||
@@ -114,6 +127,8 @@ describe('SettingsView Diff Preferences', () => {
|
||||
meta: {
|
||||
...(window as any).electronAPI?.meta,
|
||||
getCategories: vi.fn().mockResolvedValue(['article', 'picture', 'aside', 'page']),
|
||||
getPublishingPreferences: vi.fn().mockResolvedValue(null),
|
||||
setPublishingPreferences: vi.fn().mockResolvedValue({}),
|
||||
getProjectMetadata: vi.fn().mockResolvedValue({
|
||||
maxPostsPerPage: 75,
|
||||
publicUrl: 'https://example.com',
|
||||
@@ -131,6 +146,17 @@ describe('SettingsView Diff Preferences', () => {
|
||||
getSystemPrompt: vi.fn().mockResolvedValue({ success: true, prompt: '' }),
|
||||
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
||||
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: {
|
||||
...(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