Compare commits

...

10 Commits

Author SHA1 Message Date
60c8e935cf fix: one-shot and thinking models can conflict
Some checks failed
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
2026-04-21 22:30:35 +02:00
Georg Bauer
f19fde6879 Feat/generic OpenAI provider (#68)
* feat: added a generic openai endpoint provider for self-hosted models

* feat: proper vision and tool checkbox for generic endpoint

---------

Co-authored-by: hugo <hugoms@me.com>
2026-04-21 21:34:18 +02:00
dependabot[bot]
599856cdb2 chore(deps): bump protobufjs (#67)
Bumps the npm_and_yarn group with 1 update in the / directory: [protobufjs](https://github.com/protobufjs/protobuf.js).


Updates `protobufjs` from 7.5.4 to 7.5.5
- [Release notes](https://github.com/protobufjs/protobuf.js/releases)
- [Changelog](https://github.com/protobufjs/protobuf.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/protobufjs/protobuf.js/compare/protobufjs-v7.5.4...protobufjs-v7.5.5)

---
updated-dependencies:
- dependency-name: protobufjs
  dependency-version: 7.5.5
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 10:51:21 +02:00
dependabot[bot]
dcb044d8ea chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#66)
Bumps the npm_and_yarn group with 2 updates in the / directory: [follow-redirects](https://github.com/follow-redirects/follow-redirects) and [hono](https://github.com/honojs/hono).


Updates `follow-redirects` from 1.15.11 to 1.16.0
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0)

Updates `hono` from 4.12.12 to 4.12.14
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.12...v4.12.14)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-version: 1.16.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: hono
  dependency-version: 4.12.14
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-18 11:32:14 +02:00
dependabot[bot]
7f70e225f7 chore(deps): bump the npm_and_yarn group across 1 directory with 3 updates (#65)
Bumps the npm_and_yarn group with 3 updates in the / directory: [drizzle-orm](https://github.com/drizzle-team/drizzle-orm), [liquidjs](https://github.com/harttle/liquidjs) and [axios](https://github.com/axios/axios).


Updates `drizzle-orm` from 0.45.1 to 0.45.2
- [Release notes](https://github.com/drizzle-team/drizzle-orm/releases)
- [Commits](https://github.com/drizzle-team/drizzle-orm/compare/0.45.1...0.45.2)

Updates `liquidjs` from 10.25.0 to 10.25.5
- [Release notes](https://github.com/harttle/liquidjs/releases)
- [Changelog](https://github.com/harttle/liquidjs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/harttle/liquidjs/compare/v10.25.0...v10.25.5)

Updates `axios` from 1.13.5 to 1.15.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.13.5...v1.15.0)

---
updated-dependencies:
- dependency-name: drizzle-orm
  dependency-version: 0.45.2
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: liquidjs
  dependency-version: 10.25.5
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: axios
  dependency-version: 1.15.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 10:23:18 +02:00
dependabot[bot]
710f2f78a4 chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#64)
Bumps the npm_and_yarn group with 2 updates in the / directory: [@hono/node-server](https://github.com/honojs/node-server) and [hono](https://github.com/honojs/hono).


Updates `@hono/node-server` from 1.19.10 to 1.19.13
- [Release notes](https://github.com/honojs/node-server/releases)
- [Commits](https://github.com/honojs/node-server/compare/v1.19.10...v1.19.13)

Updates `hono` from 4.12.7 to 4.12.12
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.7...v4.12.12)

---
updated-dependencies:
- dependency-name: "@hono/node-server"
  dependency-version: 1.19.13
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: hono
  dependency-version: 4.12.12
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 14:53:00 +02:00
dependabot[bot]
b680b7bfff chore(deps-dev): bump the npm_and_yarn group across 1 directory with 3 updates (#63)
Bumps the npm_and_yarn group with 3 updates in the / directory: [electron](https://github.com/electron/electron), [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) and [lodash](https://github.com/lodash/lodash).


Updates `electron` from 40.4.0 to 40.8.5
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v40.4.0...v40.8.5)

Updates `vite` from 7.3.1 to 7.3.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite)

Updates `lodash` from 4.17.23 to 4.18.1
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 40.8.5
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: vite
  dependency-version: 7.3.2
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 19:04:49 +02:00
dependabot[bot]
d1669fb5b5 chore(deps): bump the npm_and_yarn group across 1 directory with 3 updates (#62)
Bumps the npm_and_yarn group with 3 updates in the / directory: [@xmldom/xmldom](https://github.com/xmldom/xmldom), [path-to-regexp](https://github.com/pillarjs/path-to-regexp) and [picomatch](https://github.com/micromatch/picomatch).


Updates `@xmldom/xmldom` from 0.8.11 to 0.8.12
- [Release notes](https://github.com/xmldom/xmldom/releases)
- [Changelog](https://github.com/xmldom/xmldom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/xmldom/xmldom/compare/0.8.11...0.8.12)

Updates `path-to-regexp` from 8.3.0 to 8.4.1
- [Release notes](https://github.com/pillarjs/path-to-regexp/releases)
- [Changelog](https://github.com/pillarjs/path-to-regexp/blob/master/History.md)
- [Commits](https://github.com/pillarjs/path-to-regexp/compare/v8.3.0...v8.4.1)

Updates `picomatch` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

---
updated-dependencies:
- dependency-name: "@xmldom/xmldom"
  dependency-version: 0.8.12
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: path-to-regexp
  dependency-version: 8.4.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-06 22:35:48 +02:00
dependabot[bot]
12dfaa9e7a chore(deps): bump smol-toml in the npm_and_yarn group across 1 directory (#61)
Bumps the npm_and_yarn group with 1 update in the / directory: [smol-toml](https://github.com/squirrelchat/smol-toml).


Updates `smol-toml` from 1.6.0 to 1.6.1
- [Release notes](https://github.com/squirrelchat/smol-toml/releases)
- [Commits](https://github.com/squirrelchat/smol-toml/compare/v1.6.0...v1.6.1)

---
updated-dependencies:
- dependency-name: smol-toml
  dependency-version: 1.6.1
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 15:28:51 +02:00
dependabot[bot]
b00230601b chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#59)
Bumps the npm_and_yarn group with 2 updates in the / directory: [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) and [flatted](https://github.com/WebReflection/flatted).


Updates `fast-xml-parser` from 5.5.6 to 5.5.7
- [Release notes](https://github.com/NaturalIntelligence/fast-xml-parser/releases)
- [Changelog](https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.6...v5.5.7)

Updates `flatted` from 3.3.3 to 3.4.2
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2)

---
updated-dependencies:
- dependency-name: fast-xml-parser
  dependency-version: 5.5.7
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 18:09:57 +01:00
19 changed files with 1307 additions and 93 deletions

141
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "blogging-desktop-server", "name": "blogging-desktop-server",
"version": "1.0.0", "version": "1.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "blogging-desktop-server", "name": "blogging-desktop-server",
"version": "1.0.0", "version": "1.0.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^3.0.50", "@ai-sdk/anthropic": "^3.0.50",
@@ -32,15 +32,15 @@
"@modelcontextprotocol/sdk": "^1.27.1", "@modelcontextprotocol/sdk": "^1.27.1",
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@picocss/pico": "^2.1.1", "@picocss/pico": "^2.1.1",
"@xmldom/xmldom": "^0.8.11", "@xmldom/xmldom": "^0.8.12",
"ai": "^6.0.105", "ai": "^6.0.105",
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"d3-cloud": "^1.2.8", "d3-cloud": "^1.2.8",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.2",
"fast-xml-parser": "^5.5.6", "fast-xml-parser": "^5.5.7",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"lightbox2": "^2.11.5", "lightbox2": "^2.11.5",
"liquidjs": "^10.25.0", "liquidjs": "^10.25.5",
"marked-react": "^3.0.2", "marked-react": "^3.0.2",
"monaco-editor": "^0.55.1", "monaco-editor": "^0.55.1",
"node-scp": "^0.0.25", "node-scp": "^0.0.25",
@@ -53,7 +53,7 @@
"rsyncwrapper": "^3.1.0", "rsyncwrapper": "^3.1.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"simple-git": "^3.32.3", "simple-git": "^3.32.3",
"smol-toml": "^1.6.0", "smol-toml": "^1.6.1",
"snowball-stemmers": "^0.6.0", "snowball-stemmers": "^0.6.0",
"transliteration": "^2.6.1", "transliteration": "^2.6.1",
"turndown": "^7.2.2", "turndown": "^7.2.2",
@@ -80,7 +80,7 @@
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"drizzle-kit": "^0.31.9", "drizzle-kit": "^0.31.9",
"electron": "^40.4.0", "electron": "^40.8.5",
"electron-builder": "^26.7.0", "electron-builder": "^26.7.0",
"eslint": "^9.39.3", "eslint": "^9.39.3",
"eslint-plugin-i18next": "^6.1.3", "eslint-plugin-i18next": "^6.1.3",
@@ -88,7 +88,7 @@
"png-to-ico": "^3.0.1", "png-to-ico": "^3.0.1",
"tsx": "^4.6.0", "tsx": "^4.6.0",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"vite": "^7.3.1", "vite": "^7.3.2",
"vitest": "^4.0.18", "vitest": "^4.0.18",
"wait-on": "^9.0.3" "wait-on": "^9.0.3"
} }
@@ -2769,9 +2769,9 @@
} }
}, },
"node_modules/@hono/node-server": { "node_modules/@hono/node-server": {
"version": "1.19.10", "version": "1.19.13",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.10.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz",
"integrity": "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==", "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.14.1" "node": ">=18.14.1"
@@ -6148,9 +6148,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@xmldom/xmldom": { "node_modules/@xmldom/xmldom": {
"version": "0.8.11", "version": "0.8.12",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
@@ -6719,15 +6719,15 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.13.5", "version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.11", "follow-redirects": "^1.15.11",
"form-data": "^4.0.5", "form-data": "^4.0.5",
"proxy-from-env": "^1.1.0" "proxy-from-env": "^2.1.0"
} }
}, },
"node_modules/bail": { "node_modules/bail": {
@@ -8177,9 +8177,9 @@
} }
}, },
"node_modules/drizzle-orm": { "node_modules/drizzle-orm": {
"version": "0.45.1", "version": "0.45.2",
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz", "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz",
"integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==", "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peerDependencies": { "peerDependencies": {
"@aws-sdk/client-rds-data": ">=3", "@aws-sdk/client-rds-data": ">=3",
@@ -8345,9 +8345,9 @@
} }
}, },
"node_modules/electron": { "node_modules/electron": {
"version": "40.4.0", "version": "40.8.5",
"resolved": "https://registry.npmjs.org/electron/-/electron-40.4.0.tgz", "resolved": "https://registry.npmjs.org/electron/-/electron-40.8.5.tgz",
"integrity": "sha512-31l4V7Ys4oUuXyaN/cCNnyBdDXN9RwOVOG+JhiHCf4zx5tZkHd43PKGY6KLEWpeYCxaphsuGSEjagJLfPqKj8g==", "integrity": "sha512-pgTY/VPQKaiU4sTjfU96iyxCXrFm4htVPCMRT4b7q9ijNTRgtLmLvcmzp2G4e7xDrq9p7OLHSmu1rBKFf6Y1/A==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
@@ -9223,9 +9223,9 @@
} }
}, },
"node_modules/fast-xml-parser": { "node_modules/fast-xml-parser": {
"version": "5.5.6", "version": "5.5.7",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.7.tgz",
"integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==", "integrity": "sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -9236,7 +9236,7 @@
"dependencies": { "dependencies": {
"fast-xml-builder": "^1.1.4", "fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.1.3", "path-expression-matcher": "^1.1.3",
"strnum": "^2.1.2" "strnum": "^2.2.0"
}, },
"bin": { "bin": {
"fxparser": "src/cli/cli.js" "fxparser": "src/cli/cli.js"
@@ -9411,16 +9411,16 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.3.3", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.15.11", "version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -9947,9 +9947,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/hono": { "node_modules/hono": {
"version": "4.12.7", "version": "4.12.14",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==", "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
@@ -10698,9 +10698,9 @@
"integrity": "sha512-IsDqv/D9pjgh7GvwTNvmHF98+nrIcOD17fraXgtx8ivq469y95l5ycLi6SeZAZHdeyD3cGLjYwbDX8SRfWx5fA==" "integrity": "sha512-IsDqv/D9pjgh7GvwTNvmHF98+nrIcOD17fraXgtx8ivq469y95l5ycLi6SeZAZHdeyD3cGLjYwbDX8SRfWx5fA=="
}, },
"node_modules/liquidjs": { "node_modules/liquidjs": {
"version": "10.25.0", "version": "10.25.5",
"resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.25.0.tgz", "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.25.5.tgz",
"integrity": "sha512-XpO7AiGULTG4xcTlwkcTI5JreFG7b6esLCLp+aUSh7YuQErJZEoUXre9u9rbdb0057pfWG4l0VursvLd5Q/eAw==", "integrity": "sha512-GKiKeZjJDdVoQAu+S9rzkYsYnYhcep5W3WwZXgb5f+yq484P/k9JqamBbGYu+LBEixcUAXZr2jogdAIjB3ki1w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"commander": "^10.0.0" "commander": "^10.0.0"
@@ -10743,16 +10743,16 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.23", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash-es": { "node_modules/lodash-es": {
"version": "4.17.23", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
@@ -12731,9 +12731,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "8.3.0", "version": "8.4.1",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.1.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "integrity": "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -12776,9 +12776,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -13224,9 +13224,9 @@
} }
}, },
"node_modules/protobufjs": { "node_modules/protobufjs": {
"version": "7.5.4", "version": "7.5.5",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz",
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
@@ -13261,11 +13261,14 @@
} }
}, },
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"engines": {
"node": ">=10"
}
}, },
"node_modules/pump": { "node_modules/pump": {
"version": "3.0.3", "version": "3.0.3",
@@ -14342,9 +14345,9 @@
} }
}, },
"node_modules/smol-toml": { "node_modules/smol-toml": {
"version": "1.6.0", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz",
"integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">= 18" "node": ">= 18"
@@ -14598,9 +14601,9 @@
} }
}, },
"node_modules/strnum": { "node_modules/strnum": {
"version": "2.1.2", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.1.tgz",
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", "integrity": "sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -15851,9 +15854,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -1,7 +1,7 @@
{ {
"name": "blogging-desktop-server", "name": "blogging-desktop-server",
"productName": "Blogging Desktop Server", "productName": "Blogging Desktop Server",
"version": "1.0.0", "version": "1.0.1",
"description": "A desktop blogging application with offline-first capabilities and cloud sync", "description": "A desktop blogging application with offline-first capabilities and cloud sync",
"main": "dist/main/main.js", "main": "dist/main/main.js",
"scripts": { "scripts": {
@@ -54,7 +54,7 @@
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"drizzle-kit": "^0.31.9", "drizzle-kit": "^0.31.9",
"electron": "^40.4.0", "electron": "^40.8.5",
"electron-builder": "^26.7.0", "electron-builder": "^26.7.0",
"eslint": "^9.39.3", "eslint": "^9.39.3",
"eslint-plugin-i18next": "^6.1.3", "eslint-plugin-i18next": "^6.1.3",
@@ -62,7 +62,7 @@
"png-to-ico": "^3.0.1", "png-to-ico": "^3.0.1",
"tsx": "^4.6.0", "tsx": "^4.6.0",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"vite": "^7.3.1", "vite": "^7.3.2",
"vitest": "^4.0.18", "vitest": "^4.0.18",
"wait-on": "^9.0.3" "wait-on": "^9.0.3"
}, },
@@ -90,15 +90,15 @@
"@modelcontextprotocol/sdk": "^1.27.1", "@modelcontextprotocol/sdk": "^1.27.1",
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@picocss/pico": "^2.1.1", "@picocss/pico": "^2.1.1",
"@xmldom/xmldom": "^0.8.11", "@xmldom/xmldom": "^0.8.12",
"ai": "^6.0.105", "ai": "^6.0.105",
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"d3-cloud": "^1.2.8", "d3-cloud": "^1.2.8",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.2",
"fast-xml-parser": "^5.5.6", "fast-xml-parser": "^5.5.7",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"lightbox2": "^2.11.5", "lightbox2": "^2.11.5",
"liquidjs": "^10.25.0", "liquidjs": "^10.25.5",
"marked-react": "^3.0.2", "marked-react": "^3.0.2",
"monaco-editor": "^0.55.1", "monaco-editor": "^0.55.1",
"node-scp": "^0.0.25", "node-scp": "^0.0.25",
@@ -111,7 +111,7 @@
"rsyncwrapper": "^3.1.0", "rsyncwrapper": "^3.1.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"simple-git": "^3.32.3", "simple-git": "^3.32.3",
"smol-toml": "^1.6.0", "smol-toml": "^1.6.1",
"snowball-stemmers": "^0.6.0", "snowball-stemmers": "^0.6.0",
"transliteration": "^2.6.1", "transliteration": "^2.6.1",
"turndown": "^7.2.2", "turndown": "^7.2.2",

View File

@@ -294,8 +294,10 @@ export class ChatService {
// Build tools (skip for Ollama/LM Studio models unless capability override is set) // Build tools (skip for Ollama/LM Studio models unless capability override is set)
const isOllama = this.providers.isOllamaModel(modelId); const isOllama = this.providers.isOllamaModel(modelId);
const isLmstudio = this.providers.isLmstudioModel(modelId); const isLmstudio = this.providers.isLmstudioModel(modelId);
const isGenericOpenAI = this.providers.isGenericOpenAIModel(modelId);
const skipTools = (isOllama && !this.providers.ollamaModelSupportsTools(modelId)) const skipTools = (isOllama && !this.providers.ollamaModelSupportsTools(modelId))
|| (isLmstudio && !this.providers.lmstudioModelSupportsTools(modelId)); || (isLmstudio && !this.providers.lmstudioModelSupportsTools(modelId))
|| (isGenericOpenAI && !this.providers.genericOpenAIModelSupportsTools(modelId));
const blogTools = skipTools ? {} : createBlogTools(this.blogToolDeps); const blogTools = skipTools ? {} : createBlogTools(this.blogToolDeps);
const a2uiToolsRaw = skipTools ? {} : createA2UITools(); const a2uiToolsRaw = skipTools ? {} : createA2UITools();
const allTools = { ...blogTools, ...a2uiToolsRaw }; const allTools = { ...blogTools, ...a2uiToolsRaw };

View File

@@ -31,10 +31,12 @@ export const OLLAMA_BASE_URL = 'http://localhost:11434/v1';
export const OLLAMA_TAGS_URL = 'http://localhost:11434/api/tags'; export const OLLAMA_TAGS_URL = 'http://localhost:11434/api/tags';
export const LMSTUDIO_BASE_URL = 'http://localhost:1234/v1'; export const LMSTUDIO_BASE_URL = 'http://localhost:1234/v1';
export const LMSTUDIO_MODELS_URL = 'http://localhost:1234/v1/models'; export const LMSTUDIO_MODELS_URL = 'http://localhost:1234/v1/models';
export const GENERIC_OPENAI_MODELS_PATH = '/models';
const MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes const MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const OLLAMA_FETCH_TIMEOUT = 3000; // 3 s — fail fast when Ollama isn't running const OLLAMA_FETCH_TIMEOUT = 3000; // 3 s — fail fast when Ollama isn't running
const LMSTUDIO_FETCH_TIMEOUT = 3000; // 3 s — fail fast when LM Studio isn't running const LMSTUDIO_FETCH_TIMEOUT = 3000; // 3 s — fail fast when LM Studio isn't running
const GENERIC_OPENAI_FETCH_TIMEOUT = 10000; // 10 s for generic endpoints
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Gateway factory // Gateway factory
@@ -123,6 +125,12 @@ export class ProviderRegistry {
private lmstudioProvider: ReturnType<typeof createOpenAI> | null = null; private lmstudioProvider: ReturnType<typeof createOpenAI> | null = null;
private lmstudioModelIds = new Set<string>(); private lmstudioModelIds = new Set<string>();
private lmstudioCapabilities = new Map<string, { tools: boolean; vision: boolean }>(); private lmstudioCapabilities = new Map<string, { tools: boolean; vision: boolean }>();
private genericOpenAIEnabled = false;
private genericOpenAIBaseURL = '';
private genericOpenAIApiKey = '';
private genericOpenAIProvider: ReturnType<typeof createOpenAI> | null = null;
private genericOpenAIModelIds = new Set<string>();
private genericOpenAICapabilities = new Map<string, { tools: boolean; vision: boolean; disableThinking: boolean }>();
private modelCatalogEngine = new ModelCatalogEngine(); private modelCatalogEngine = new ModelCatalogEngine();
private _offlineMode = false; private _offlineMode = false;
@@ -297,9 +305,107 @@ export class ProviderRegistry {
return this.lmstudioCapabilities.get(modelId)?.vision ?? false; return this.lmstudioCapabilities.get(modelId)?.vision ?? false;
} }
// ---- Generic OpenAI-compatible endpoint management ----
setGenericOpenAIEnabled(enabled: boolean): void {
this.genericOpenAIEnabled = enabled;
this.genericOpenAIProvider = null;
this.invalidateModelCache();
}
isGenericOpenAIEnabled(): boolean {
return this.genericOpenAIEnabled;
}
setGenericOpenAIBaseURL(baseURL: string): void {
this.genericOpenAIBaseURL = this.normalizeGenericOpenAIBaseURL(baseURL);
this.genericOpenAIProvider = null;
this.invalidateModelCache();
}
getGenericOpenAIBaseURL(): string {
return this.genericOpenAIBaseURL;
}
setGenericOpenAIApiKey(apiKey: string): void {
this.genericOpenAIApiKey = apiKey;
this.genericOpenAIProvider = null;
this.invalidateModelCache();
}
getGenericOpenAIApiKey(): string {
return this.genericOpenAIApiKey;
}
/** Register a model ID as belonging to the generic OpenAI endpoint. */
registerGenericOpenAIModel(modelId: string): void {
this.genericOpenAIModelIds.add(modelId);
}
/** Check whether a model ID was registered as a generic OpenAI model. */
isGenericOpenAIModel(modelId: string): boolean {
return this.genericOpenAIModelIds.has(modelId);
}
/** Remove all registered generic OpenAI model IDs. */
clearGenericOpenAIModels(): void {
this.genericOpenAIModelIds.clear();
}
/** Get capability overrides for a specific generic OpenAI model. */
getGenericOpenAIModelCapabilities(modelId: string): { tools: boolean; vision: boolean; disableThinking: boolean } {
return this.genericOpenAICapabilities.get(modelId) ?? { tools: false, vision: false, disableThinking: false };
}
/** Set capability overrides for a specific generic OpenAI model. */
setGenericOpenAIModelCapabilities(modelId: string, caps: { tools: boolean; vision: boolean; disableThinking?: boolean }): void {
this.genericOpenAICapabilities.set(modelId, {
tools: caps.tools,
vision: caps.vision,
disableThinking: caps.disableThinking ?? false,
});
this.invalidateModelCache();
}
/** Get all stored generic OpenAI capability overrides. */
getAllGenericOpenAIModelCapabilities(): Record<string, { tools: boolean; vision: boolean; disableThinking: boolean }> {
const result: Record<string, { tools: boolean; vision: boolean; disableThinking: boolean }> = {};
for (const [id, caps] of this.genericOpenAICapabilities) {
result[id] = caps;
}
return result;
}
/** Load generic OpenAI capability overrides from serialized object. */
loadGenericOpenAIModelCapabilities(data: Record<string, { tools: boolean; vision: boolean; disableThinking?: boolean }>): void {
this.genericOpenAICapabilities.clear();
for (const [id, caps] of Object.entries(data)) {
this.genericOpenAICapabilities.set(id, {
tools: caps.tools,
vision: caps.vision,
disableThinking: caps.disableThinking ?? false,
});
}
}
/** Check whether a generic OpenAI model has tools capability enabled. */
genericOpenAIModelSupportsTools(modelId: string): boolean {
return this.genericOpenAICapabilities.get(modelId)?.tools ?? false;
}
/** Check whether a generic OpenAI model has vision capability enabled. */
genericOpenAIModelSupportsVision(modelId: string): boolean {
return this.genericOpenAICapabilities.get(modelId)?.vision ?? false;
}
/** Check whether a generic OpenAI model should disable reasoning/thinking output. */
genericOpenAIModelDisablesThinking(modelId: string): boolean {
return this.genericOpenAICapabilities.get(modelId)?.disableThinking ?? false;
}
/** /**
* Detect the effective provider for a model ID, checking Ollama and LM Studio * Detect the effective provider for a model ID, checking Ollama, LM Studio,
* registration first, then falling back to prefix-based detection. * and generic OpenAI registration first, then falling back to prefix-based detection.
*/ */
detectModelProvider(modelId: string): string { detectModelProvider(modelId: string): string {
if (this.ollamaModelIds.has(modelId)) { if (this.ollamaModelIds.has(modelId)) {
@@ -308,6 +414,9 @@ export class ProviderRegistry {
if (this.lmstudioModelIds.has(modelId)) { if (this.lmstudioModelIds.has(modelId)) {
return 'lmstudio'; return 'lmstudio';
} }
if (this.genericOpenAIModelIds.has(modelId)) {
return 'generic-openai';
}
return detectProvider(modelId); return detectProvider(modelId);
} }
@@ -316,7 +425,7 @@ export class ProviderRegistry {
if (this._offlineMode) { if (this._offlineMode) {
return !!(this.ollamaEnabled || this.lmstudioEnabled); return !!(this.ollamaEnabled || this.lmstudioEnabled);
} }
return !!(this.opencodeKey || this.mistralKey || this.ollamaEnabled || this.lmstudioEnabled); return !!(this.opencodeKey || this.mistralKey || this.ollamaEnabled || this.lmstudioEnabled || this.genericOpenAIEnabled);
} }
/** Check whether the key for a specific provider is set. */ /** Check whether the key for a specific provider is set. */
@@ -327,6 +436,9 @@ export class ProviderRegistry {
if (provider === 'lmstudio') { if (provider === 'lmstudio') {
return this.lmstudioEnabled; return this.lmstudioEnabled;
} }
if (provider === 'generic-openai') {
return this.genericOpenAIEnabled && Boolean(this.genericOpenAIBaseURL);
}
// In offline mode, cloud providers are unavailable // In offline mode, cloud providers are unavailable
if (this._offlineMode) { if (this._offlineMode) {
return false; return false;
@@ -338,12 +450,13 @@ export class ProviderRegistry {
} }
/** Returns status of all configured providers. */ /** Returns status of all configured providers. */
getProviderStatus(): { opencode: boolean; mistral: boolean; ollama: boolean; lmstudio: boolean; offlineMode: boolean } { getProviderStatus(): { opencode: boolean; mistral: boolean; ollama: boolean; lmstudio: boolean; genericOpenAI: boolean; offlineMode: boolean } {
return { return {
opencode: !!this.opencodeKey, opencode: !!this.opencodeKey,
mistral: !!this.mistralKey, mistral: !!this.mistralKey,
ollama: this.ollamaEnabled, ollama: this.ollamaEnabled,
lmstudio: this.lmstudioEnabled, lmstudio: this.lmstudioEnabled,
genericOpenAI: this.genericOpenAIEnabled,
offlineMode: this._offlineMode, offlineMode: this._offlineMode,
}; };
} }
@@ -385,6 +498,52 @@ export class ProviderRegistry {
return this.lmstudioProvider.chat(modelId); return this.lmstudioProvider.chat(modelId);
} }
// Check if this is a registered generic OpenAI model
if (this.genericOpenAIModelIds.has(modelId)) {
if (!this.genericOpenAIEnabled || !this.genericOpenAIBaseURL) {
throw new Error(`Generic OpenAI endpoint not configured for model '${modelId}'`);
}
if (!this.genericOpenAIProvider) {
const genericFetch: typeof fetch = async (input, init) => {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
if (url.endsWith('/chat/completions') && typeof init?.body === 'string') {
try {
const body = JSON.parse(init.body) as { model?: string; chat_template_kwargs?: Record<string, unknown> };
if (body.model && this.genericOpenAIModelDisablesThinking(body.model)) {
const nextInit = {
...init,
body: JSON.stringify({
...body,
chat_template_kwargs: {
...body.chat_template_kwargs,
enable_thinking: false,
},
}),
};
return fetch(input, nextInit);
}
} catch {
// Fall back to the original request if the body isn't JSON.
}
}
return fetch(input, init);
};
this.genericOpenAIProvider = createOpenAI({
baseURL: this.genericOpenAIBaseURL,
apiKey: this.genericOpenAIApiKey || 'dummy-key',
fetch: genericFetch,
});
}
return this.genericOpenAIProvider.chat(modelId);
}
const provider = detectProvider(modelId); const provider = detectProvider(modelId);
if (provider === 'mistral') { if (provider === 'mistral') {
@@ -544,6 +703,19 @@ export class ProviderRegistry {
} }
} }
// Fetch generic OpenAI-compatible endpoint models
if (this.genericOpenAIEnabled && this.genericOpenAIBaseURL && !this._offlineMode) {
try {
const models = await this.fetchGenericOpenAIModels();
allModels.push(...models);
if (models.length > 0) {
fetched = true;
}
} catch {
// Generic OpenAI endpoint not available — skip silently
}
}
if (fetched && allModels.length > 0) { if (fetched && allModels.length > 0) {
this.cachedModels = allModels; this.cachedModels = allModels;
this.cachedModelsAt = Date.now(); this.cachedModelsAt = Date.now();
@@ -678,6 +850,78 @@ export class ProviderRegistry {
} }
} }
// ---- Generic OpenAI-compatible endpoint model listing ----
/**
* Fetch available models from a generic OpenAI-compatible /v1/models endpoint.
* Returns ChatModel[] and registers the model IDs internally.
*/
async fetchGenericOpenAIModels(): Promise<ChatModel[]> {
const normalizedBaseURL = this.normalizeGenericOpenAIBaseURL(this.genericOpenAIBaseURL);
if (!normalizedBaseURL) {
return [];
}
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), GENERIC_OPENAI_FETCH_TIMEOUT);
const headers: Record<string, string> = {};
if (this.genericOpenAIApiKey) {
headers.Authorization = `Bearer ${this.genericOpenAIApiKey}`;
}
const response = await fetch(`${normalizedBaseURL}${GENERIC_OPENAI_MODELS_PATH}`, {
method: 'GET',
headers,
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
return [];
}
const data = await response.json() as { data?: Array<{ id: string }> };
if (!data.data || !Array.isArray(data.data)) {
return [];
}
const models: ChatModel[] = data.data.map(m => ({
id: m.id,
name: m.id,
provider: 'generic-openai',
vision: this.genericOpenAIModelSupportsVision(m.id),
}));
// Only replace registered IDs on successful fetch
this.clearGenericOpenAIModels();
for (const m of models) {
this.registerGenericOpenAIModel(m.id);
}
return models;
} catch {
return [];
}
}
/**
* Validate generic OpenAI endpoint configuration by fetching models.
*/
async validateGenericOpenAIConfig(): Promise<{ isValid: boolean; models: ChatModel[]; error?: string }> {
if (!this.genericOpenAIBaseURL) {
return { isValid: false, models: [], error: 'Base URL is required' };
}
try {
const models = await this.fetchGenericOpenAIModels();
return { isValid: true, models };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { isValid: false, models: [], error: errorMessage };
}
}
// ---- Private helpers ---- // ---- Private helpers ----
private async fetchModelsFromEndpoint( private async fetchModelsFromEndpoint(
@@ -725,6 +969,14 @@ export class ProviderRegistry {
return { vision, names }; return { vision, names };
} }
private normalizeGenericOpenAIBaseURL(baseURL: string): string {
const trimmed = baseURL.trim().replace(/\/+$/, '');
if (!trimmed) {
return '';
}
return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`;
}
private async getModelsFromCatalog(): Promise<ChatModel[]> { private async getModelsFromCatalog(): Promise<ChatModel[]> {
try { try {
const catalog = await this.modelCatalogEngine.getAll(); const catalog = await this.modelCatalogEngine.getAll();

View File

@@ -101,7 +101,7 @@ const SHARED_JS = `\
document.getElementById("status").textContent = "Error: App bundle not loaded"; document.getElementById("status").className = "status status-error"; document.getElementById("status").style.display = "block"; throw new Error("App bundle not loaded"); document.getElementById("status").textContent = "Error: App bundle not loaded"; document.getElementById("status").className = "status status-error"; document.getElementById("status").style.display = "block"; throw new Error("App bundle not loaded");
} }
const app = new App({ name: "bDS Review", version: "1.0.0" }); const app = new App({ name: "bDS Review", version: "1.0.1" });
let currentData = null; let currentData = null;

View File

@@ -132,6 +132,13 @@ async function ensureInitialized(): Promise<void> {
} }
} catch { /* ignore */ } } catch { /* ignore */ }
try {
const genericOpenAIKey = await keyStore.retrieve('generic_openai_api_key');
if (genericOpenAIKey) {
reg.setGenericOpenAIApiKey(genericOpenAIKey);
}
} catch { /* ignore */ }
// Restore Ollama enabled state from settings DB // Restore Ollama enabled state from settings DB
try { try {
const ollamaEnabled = await getChatEngine().getSetting('ollama_enabled'); const ollamaEnabled = await getChatEngine().getSetting('ollama_enabled');
@@ -167,6 +174,21 @@ async function ensureInitialized(): Promise<void> {
} }
} catch { /* ignore */ } } catch { /* ignore */ }
// Restore generic OpenAI enabled state and base URL from settings DB
try {
const genericOpenAIEnabled = await getChatEngine().getSetting('generic_openai_enabled');
if (genericOpenAIEnabled === 'true') {
reg.setGenericOpenAIEnabled(true);
}
} catch { /* ignore */ }
try {
const genericOpenAIBaseURL = await getChatEngine().getSetting('generic_openai_base_url');
if (genericOpenAIBaseURL) {
reg.setGenericOpenAIBaseURL(genericOpenAIBaseURL);
}
} catch { /* ignore */ }
// Restore LM Studio model capability overrides // Restore LM Studio model capability overrides
try { try {
const lmCapsJson = await getChatEngine().getSetting('lmstudio_model_capabilities'); const lmCapsJson = await getChatEngine().getSetting('lmstudio_model_capabilities');
@@ -176,6 +198,15 @@ async function ensureInitialized(): Promise<void> {
} }
} catch { /* ignore */ } } catch { /* ignore */ }
// Restore generic OpenAI model capability overrides
try {
const genericCapsJson = await getChatEngine().getSetting('generic_openai_model_capabilities');
if (genericCapsJson) {
const caps = JSON.parse(genericCapsJson) as Record<string, { tools: boolean; vision: boolean }>;
reg.loadGenericOpenAIModelCapabilities(caps);
}
} catch { /* ignore */ }
// Restore known LM Studio model IDs (so offline mode works without a fresh fetch) // Restore known LM Studio model IDs (so offline mode works without a fresh fetch)
try { try {
const lmIds = await getChatEngine().getSetting('lmstudio_known_model_ids'); const lmIds = await getChatEngine().getSetting('lmstudio_known_model_ids');
@@ -186,6 +217,16 @@ async function ensureInitialized(): Promise<void> {
} }
} catch { /* ignore */ } } catch { /* ignore */ }
// Restore known generic OpenAI model IDs for provider routing before a refresh
try {
const genericIds = await getChatEngine().getSetting('generic_openai_known_model_ids');
if (genericIds) {
for (const id of JSON.parse(genericIds) as string[]) {
reg.registerGenericOpenAIModel(id);
}
}
} catch { /* ignore */ }
// Restore offline mode from settings or auto-detect via OS network status // Restore offline mode from settings or auto-detect via OS network status
try { try {
const savedOffline = await getChatEngine().getSetting('offline_mode'); const savedOffline = await getChatEngine().getSetting('offline_mode');
@@ -468,6 +509,147 @@ export function registerChatHandlers(): void {
} }
}); });
// ============ Generic OpenAI-compatible Endpoint ============
// Get generic OpenAI enabled state
ipcMain.handle('chat:getGenericOpenAIEnabled', async () => {
try {
await ensureInitialized();
return getProviders().isGenericOpenAIEnabled();
} catch (error) {
console.error('[Chat IPC] Error getting generic OpenAI enabled state:', error);
return false;
}
});
// Set generic OpenAI enabled state
ipcMain.handle('chat:setGenericOpenAIEnabled', async (_, enabled: boolean) => {
try {
await ensureInitialized();
const reg = getProviders();
reg.setGenericOpenAIEnabled(enabled);
await getChatEngine().setSetting('generic_openai_enabled', enabled ? 'true' : 'false');
return { success: true };
} catch (error) {
console.error('[Chat IPC] Error setting generic OpenAI enabled state:', error);
return { success: false, error: (error as Error).message };
}
});
// Get generic OpenAI base URL
ipcMain.handle('chat:getGenericOpenAIBaseURL', async () => {
try {
await ensureInitialized();
return getProviders().getGenericOpenAIBaseURL();
} catch (error) {
console.error('[Chat IPC] Error getting generic OpenAI base URL:', error);
return '';
}
});
// Set generic OpenAI base URL
ipcMain.handle('chat:setGenericOpenAIBaseURL', async (_, baseURL: string) => {
try {
await ensureInitialized();
const reg = getProviders();
reg.setGenericOpenAIBaseURL(baseURL);
await getChatEngine().setSetting('generic_openai_base_url', baseURL);
return { success: true };
} catch (error) {
console.error('[Chat IPC] Error setting generic OpenAI base URL:', error);
return { success: false, error: (error as Error).message };
}
});
// Get generic OpenAI API key (masked)
ipcMain.handle('chat:getGenericOpenAIApiKey', async () => {
try {
await ensureInitialized();
const key = getProviders().getGenericOpenAIApiKey();
if (!key) {
return { hasKey: false, maskedKey: '' };
}
const masked = '•'.repeat(Math.max(0, key.length - 4)) + key.slice(-4);
return { hasKey: true, maskedKey: masked };
} catch (error) {
console.error('[Chat IPC] Error getting generic OpenAI API key:', error);
return { hasKey: false, maskedKey: '' };
}
});
// Set generic OpenAI API key
ipcMain.handle('chat:setGenericOpenAIApiKey', async (_, apiKey: string) => {
try {
await ensureInitialized();
const reg = getProviders();
const previousKey = reg.getGenericOpenAIApiKey();
reg.setGenericOpenAIApiKey(apiKey);
// Persist to encrypted storage — roll back in-memory key on failure
try {
await getSecureKeyStore().store('generic_openai_api_key', apiKey);
} catch (storeError) {
reg.setGenericOpenAIApiKey(previousKey);
throw storeError;
}
return { success: true };
} catch (error) {
console.error('[Chat IPC] Error setting generic OpenAI API key:', error);
return { success: false, error: (error as Error).message };
}
});
// Validate generic OpenAI configuration
ipcMain.handle('chat:validateGenericOpenAIConfig', async () => {
try {
await ensureInitialized();
return await getProviders().validateGenericOpenAIConfig();
} catch (error) {
console.error('[Chat IPC] Error validating generic OpenAI config:', error);
return { isValid: false, models: [], error: (error as Error).message };
}
});
// Fetch generic OpenAI models
ipcMain.handle('chat:getGenericOpenAIModels', async () => {
try {
await ensureInitialized();
return await getProviders().fetchGenericOpenAIModels();
} catch (error) {
console.error('[Chat IPC] Error fetching generic OpenAI models:', error);
return [];
}
});
// Get generic OpenAI model capability overrides
ipcMain.handle('chat:getGenericOpenAIModelCapabilities', async () => {
try {
await ensureInitialized();
return getProviders().getAllGenericOpenAIModelCapabilities();
} catch (error) {
console.error('[Chat IPC] Error getting generic OpenAI model capabilities:', error);
return {};
}
});
// Set capability override for a single generic OpenAI model
ipcMain.handle('chat:setGenericOpenAIModelCapabilities', async (_, modelId: string, caps: { tools: boolean; vision: boolean; disableThinking: boolean }) => {
try {
await ensureInitialized();
const reg = getProviders();
reg.setGenericOpenAIModelCapabilities(modelId, caps);
// Persist all capabilities to settings DB
const allCaps = reg.getAllGenericOpenAIModelCapabilities();
await getChatEngine().setSetting('generic_openai_model_capabilities', JSON.stringify(allCaps));
return { success: true };
} catch (error) {
console.error('[Chat IPC] Error setting generic OpenAI model capabilities:', error);
return { success: false, error: (error as Error).message };
}
});
// ============ Offline / Airplane Mode ============ // ============ Offline / Airplane Mode ============
ipcMain.handle('chat:getOfflineMode', async () => { ipcMain.handle('chat:getOfflineMode', async () => {
@@ -627,12 +809,16 @@ export function registerChatHandlers(): void {
// Persist known local model IDs so offline mode survives restarts // Persist known local model IDs so offline mode survives restarts
const ollamaModels = models.filter(m => m.provider === 'ollama').map(m => m.id); const ollamaModels = models.filter(m => m.provider === 'ollama').map(m => m.id);
const lmstudioModels = models.filter(m => m.provider === 'lmstudio').map(m => m.id); const lmstudioModels = models.filter(m => m.provider === 'lmstudio').map(m => m.id);
const genericOpenAIModels = models.filter(m => m.provider === 'generic-openai').map(m => m.id);
if (ollamaModels.length > 0) { if (ollamaModels.length > 0) {
await engine.setSetting('ollama_known_model_ids', JSON.stringify(ollamaModels)).catch(() => {}); await engine.setSetting('ollama_known_model_ids', JSON.stringify(ollamaModels)).catch(() => {});
} }
if (lmstudioModels.length > 0) { if (lmstudioModels.length > 0) {
await engine.setSetting('lmstudio_known_model_ids', JSON.stringify(lmstudioModels)).catch(() => {}); await engine.setSetting('lmstudio_known_model_ids', JSON.stringify(lmstudioModels)).catch(() => {});
} }
if (genericOpenAIModels.length > 0) {
await engine.setSetting('generic_openai_known_model_ids', JSON.stringify(genericOpenAIModels)).catch(() => {});
}
return { success: true, models, selectedModel }; return { success: true, models, selectedModel };
} catch (error) { } catch (error) {

View File

@@ -357,6 +357,18 @@ export const electronAPI: ElectronAPI = {
getLmstudioModelCapabilities: () => ipcRenderer.invoke('chat:getLmstudioModelCapabilities'), getLmstudioModelCapabilities: () => ipcRenderer.invoke('chat:getLmstudioModelCapabilities'),
setLmstudioModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => ipcRenderer.invoke('chat:setLmstudioModelCapabilities', modelId, caps), setLmstudioModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => ipcRenderer.invoke('chat:setLmstudioModelCapabilities', modelId, caps),
// Generic OpenAI-compatible Endpoint
getGenericOpenAIEnabled: () => ipcRenderer.invoke('chat:getGenericOpenAIEnabled'),
setGenericOpenAIEnabled: (enabled: boolean) => ipcRenderer.invoke('chat:setGenericOpenAIEnabled', enabled),
getGenericOpenAIBaseURL: () => ipcRenderer.invoke('chat:getGenericOpenAIBaseURL'),
setGenericOpenAIBaseURL: (baseURL: string) => ipcRenderer.invoke('chat:setGenericOpenAIBaseURL', baseURL),
getGenericOpenAIApiKey: () => ipcRenderer.invoke('chat:getGenericOpenAIApiKey'),
setGenericOpenAIApiKey: (apiKey: string) => ipcRenderer.invoke('chat:setGenericOpenAIApiKey', apiKey),
validateGenericOpenAIConfig: () => ipcRenderer.invoke('chat:validateGenericOpenAIConfig'),
getGenericOpenAIModels: () => ipcRenderer.invoke('chat:getGenericOpenAIModels'),
getGenericOpenAIModelCapabilities: () => ipcRenderer.invoke('chat:getGenericOpenAIModelCapabilities'),
setGenericOpenAIModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean; disableThinking: boolean }) => ipcRenderer.invoke('chat:setGenericOpenAIModelCapabilities', modelId, caps),
// Offline / Airplane Mode // Offline / Airplane Mode
getOfflineMode: () => ipcRenderer.invoke('chat:getOfflineMode'), getOfflineMode: () => ipcRenderer.invoke('chat:getOfflineMode'),
setOfflineMode: (enabled: boolean) => ipcRenderer.invoke('chat:setOfflineMode', enabled), setOfflineMode: (enabled: boolean) => ipcRenderer.invoke('chat:setOfflineMode', enabled),

View File

@@ -495,7 +495,7 @@ export interface ChatReadyStatus {
ready: boolean; ready: boolean;
error?: string; error?: string;
backend?: string; backend?: string;
providers?: { opencode: boolean; mistral: boolean; ollama: boolean; lmstudio: boolean; offlineMode: boolean }; providers?: { opencode: boolean; mistral: boolean; ollama: boolean; lmstudio: boolean; genericOpenAI: boolean; offlineMode: boolean };
} }
export interface ChatApiKeyStatus { export interface ChatApiKeyStatus {
@@ -1022,6 +1022,18 @@ export interface ElectronAPI {
getLmstudioModelCapabilities: () => Promise<Record<string, { tools: boolean; vision: boolean }>>; getLmstudioModelCapabilities: () => Promise<Record<string, { tools: boolean; vision: boolean }>>;
setLmstudioModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => Promise<{ success: boolean; error?: string }>; setLmstudioModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => Promise<{ success: boolean; error?: string }>;
// Generic OpenAI-compatible endpoint
getGenericOpenAIEnabled: () => Promise<boolean>;
setGenericOpenAIEnabled: (enabled: boolean) => Promise<{ success: boolean; error?: string }>;
getGenericOpenAIBaseURL: () => Promise<string>;
setGenericOpenAIBaseURL: (baseURL: string) => Promise<{ success: boolean; error?: string }>;
getGenericOpenAIApiKey: () => Promise<ChatApiKeyStatus>;
setGenericOpenAIApiKey: (apiKey: string) => Promise<{ success: boolean; error?: string }>;
validateGenericOpenAIConfig: () => Promise<{ isValid: boolean; models: ChatModel[]; error?: string }>;
getGenericOpenAIModels: () => Promise<ChatModel[]>;
getGenericOpenAIModelCapabilities: () => Promise<Record<string, { tools: boolean; vision: boolean; disableThinking: boolean }>>;
setGenericOpenAIModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean; disableThinking: boolean }) => Promise<{ success: boolean; error?: string }>;
// Offline / Airplane mode // Offline / Airplane mode
getOfflineMode: () => Promise<boolean>; getOfflineMode: () => Promise<boolean>;
setOfflineMode: (enabled: boolean) => Promise<{ success: boolean; error?: string }>; setOfflineMode: (enabled: boolean) => Promise<{ success: boolean; error?: string }>;

View File

@@ -253,6 +253,13 @@ export const SettingsView: React.FC = () => {
const [lmstudioEnabled, setLmstudioEnabled] = useState(false); const [lmstudioEnabled, setLmstudioEnabled] = useState(false);
const [lmstudioCapabilities, setLmstudioCapabilities] = useState<Record<string, { tools: boolean; vision: boolean }>>({}); const [lmstudioCapabilities, setLmstudioCapabilities] = useState<Record<string, { tools: boolean; vision: boolean }>>({});
const [lmstudioModels, setLmstudioModels] = useState<{id: string; name: string}[]>([]); const [lmstudioModels, setLmstudioModels] = useState<{id: string; name: string}[]>([]);
const [genericOpenAIEnabled, setGenericOpenAIEnabled] = useState(false);
const [genericOpenAIBaseURL, setGenericOpenAIBaseURL] = useState('');
const [genericOpenAIApiKeyMasked, setGenericOpenAIApiKeyMasked] = useState('');
const [hasGenericOpenAIApiKey, setHasGenericOpenAIApiKey] = useState(false);
const [newGenericOpenAIApiKey, setNewGenericOpenAIApiKey] = useState('');
const [genericOpenAIModels, setGenericOpenAIModels] = useState<{id: string; name: string}[]>([]);
const [genericOpenAICapabilities, setGenericOpenAICapabilities] = useState<Record<string, { tools: boolean; vision: boolean; disableThinking: boolean }>>({});
const [offlineModeEnabled, setOfflineModeEnabled] = useState(false); const [offlineModeEnabled, setOfflineModeEnabled] = useState(false);
const [offlineChatModel, setOfflineChatModel] = useState(''); const [offlineChatModel, setOfflineChatModel] = useState('');
const [offlineTitleModel, setOfflineTitleModel] = useState(''); const [offlineTitleModel, setOfflineTitleModel] = useState('');
@@ -464,6 +471,33 @@ export const SettingsView: React.FC = () => {
if (lmModels) setLmstudioModels(lmModels.map(m => ({ id: m.id, name: m.name }))); if (lmModels) setLmstudioModels(lmModels.map(m => ({ id: m.id, name: m.name })));
} }
// Load generic OpenAI enabled state
const genericOpenAIState = await window.electronAPI?.chat.getGenericOpenAIEnabled();
setGenericOpenAIEnabled(!!genericOpenAIState);
// Load generic OpenAI base URL
const genericOpenAIBaseURLResult = await window.electronAPI?.chat.getGenericOpenAIBaseURL();
if (genericOpenAIBaseURLResult) {
setGenericOpenAIBaseURL(genericOpenAIBaseURLResult);
}
// Load generic OpenAI API key status
const genericOpenAIApiKeyResult = await window.electronAPI?.chat.getGenericOpenAIApiKey();
if (genericOpenAIApiKeyResult) {
setHasGenericOpenAIApiKey(genericOpenAIApiKeyResult.hasKey);
setGenericOpenAIApiKeyMasked(genericOpenAIApiKeyResult.maskedKey || '');
}
// Load generic OpenAI model capabilities and models list
if (genericOpenAIState) {
const [genCaps, genModels] = await Promise.all([
window.electronAPI?.chat.getGenericOpenAIModelCapabilities(),
window.electronAPI?.chat.getGenericOpenAIModels(),
]);
if (genCaps) setGenericOpenAICapabilities(genCaps);
if (genModels) setGenericOpenAIModels(genModels.map(m => ({ id: m.id, name: m.name })));
}
// Load per-purpose model preferences // Load per-purpose model preferences
const titleModelResult = await window.electronAPI?.chat.getTitleModel(); const titleModelResult = await window.electronAPI?.chat.getTitleModel();
if (titleModelResult?.success && titleModelResult.modelId) { if (titleModelResult?.success && titleModelResult.modelId) {
@@ -1340,6 +1374,116 @@ export const SettingsView: React.FC = () => {
} }
}; };
// Generic OpenAI handlers
const handleGenericOpenAIToggle = async (enabled: boolean) => {
try {
const result = await window.electronAPI?.chat.setGenericOpenAIEnabled(enabled);
if (result?.success) {
setGenericOpenAIEnabled(enabled);
showToast.success(t(enabled ? 'settings.toast.genericOpenAIEnabled' : 'settings.toast.genericOpenAIDisabled'));
// Refresh models after toggle
const modelsResult = await window.electronAPI?.chat.getAvailableModels();
if (modelsResult?.success && modelsResult.models) {
setAvailableModels(modelsResult.models);
setSelectedModel(modelsResult.selectedModel || '');
}
// Load generic OpenAI models and capabilities when enabling
if (enabled) {
const [caps, genModelsList] = await Promise.all([
window.electronAPI?.chat.getGenericOpenAIModelCapabilities(),
window.electronAPI?.chat.getGenericOpenAIModels(),
]);
if (caps) setGenericOpenAICapabilities(caps);
if (genModelsList) setGenericOpenAIModels(genModelsList.map(m => ({ id: m.id, name: m.name })));
} else {
setGenericOpenAIModels([]);
}
}
} catch (error) {
console.error('Failed to toggle generic OpenAI:', error);
}
};
const handleSaveGenericOpenAIBaseURL = async () => {
try {
const result = await window.electronAPI?.chat.setGenericOpenAIBaseURL(genericOpenAIBaseURL);
if (!result?.success) {
throw new Error(result?.error || 'Failed to save generic OpenAI base URL');
}
const storedBaseURL = await window.electronAPI?.chat.getGenericOpenAIBaseURL();
if (typeof storedBaseURL === 'string') {
setGenericOpenAIBaseURL(storedBaseURL);
}
const [caps, genModelsList, modelsResult] = await Promise.all([
window.electronAPI?.chat.getGenericOpenAIModelCapabilities(),
window.electronAPI?.chat.getGenericOpenAIModels(),
window.electronAPI?.chat.getAvailableModels(),
]);
setGenericOpenAICapabilities(caps || {});
setGenericOpenAIModels((genModelsList || []).map(m => ({ id: m.id, name: m.name })));
if (modelsResult?.success && modelsResult.models) {
setAvailableModels(modelsResult.models);
setSelectedModel(modelsResult.selectedModel || '');
}
showToast.success(t('settings.toast.genericOpenAISettingsSaved'));
} catch (error) {
console.error('Failed to save generic OpenAI base URL:', error);
showToast.error(t('settings.toast.genericOpenAISettingsSaveFailed'));
}
};
const handleSaveGenericOpenAIApiKey = async () => {
if (!newGenericOpenAIApiKey.trim()) return;
try {
const trimmedKey = newGenericOpenAIApiKey.trim();
const result = await window.electronAPI?.chat.setGenericOpenAIApiKey(trimmedKey);
if (!result?.success) {
throw new Error(result?.error || 'Failed to save generic OpenAI API key');
}
setHasGenericOpenAIApiKey(true);
setGenericOpenAIApiKeyMasked('•'.repeat(Math.max(0, trimmedKey.length - 4)) + trimmedKey.slice(-4));
setNewGenericOpenAIApiKey('');
showToast.success(t('settings.toast.apiKeySaved'));
const [caps, genModelsList, modelsResult] = await Promise.all([
window.electronAPI?.chat.getGenericOpenAIModelCapabilities(),
window.electronAPI?.chat.getGenericOpenAIModels(),
window.electronAPI?.chat.getAvailableModels(),
]);
setGenericOpenAICapabilities(caps || {});
setGenericOpenAIModels((genModelsList || []).map(m => ({ id: m.id, name: m.name })));
if (modelsResult?.success && modelsResult.models) {
setAvailableModels(modelsResult.models);
setSelectedModel(modelsResult.selectedModel || '');
}
} catch (error) {
console.error('Failed to save generic OpenAI API key:', error);
showToast.error(t('settings.toast.apiKeySaveFailed'));
}
};
const handleGenericOpenAICapabilityToggle = async (modelId: string, field: 'tools' | 'vision' | 'disableThinking', value: boolean) => {
const current = genericOpenAICapabilities[modelId] ?? { tools: false, vision: false, disableThinking: false };
const updated = { ...current, [field]: value };
try {
const result = await window.electronAPI?.chat.setGenericOpenAIModelCapabilities(modelId, updated);
if (result?.success) {
setGenericOpenAICapabilities(prev => ({ ...prev, [modelId]: updated }));
// Refresh available models to reflect vision change
const modelsResult = await window.electronAPI?.chat.getAvailableModels();
if (modelsResult?.success && modelsResult.models) {
setAvailableModels(modelsResult.models);
}
}
} catch (error) {
console.error('Failed to update generic OpenAI model capabilities:', error);
}
};
const handleTitleModelChange = async (modelId: string) => { const handleTitleModelChange = async (modelId: string) => {
try { try {
const result = await window.electronAPI?.chat.setTitleModel(modelId); const result = await window.electronAPI?.chat.setTitleModel(modelId);
@@ -1488,6 +1632,7 @@ export const SettingsView: React.FC = () => {
if (provider === 'mistral') return t('settings.ai.providerMistral'); if (provider === 'mistral') return t('settings.ai.providerMistral');
if (provider === 'ollama') return t('settings.ai.providerOllama'); if (provider === 'ollama') return t('settings.ai.providerOllama');
if (provider === 'lmstudio') return t('settings.ai.providerLmstudio'); if (provider === 'lmstudio') return t('settings.ai.providerLmstudio');
if (provider === 'generic-openai') return t('settings.ai.providerGenericOpenAI');
return provider; return provider;
}; };
@@ -1716,6 +1861,125 @@ export const SettingsView: React.FC = () => {
)} )}
</SettingRow> </SettingRow>
<SettingRow
id="ai-generic-openai"
label={t('settings.ai.genericOpenAILabel')}
description={t('settings.ai.genericOpenOIDescription')}
>
<div className="setting-input-group">
<label className="toggle-label">
<input
id="ai-generic-openai"
type="checkbox"
checked={genericOpenAIEnabled}
onChange={(e) => handleGenericOpenAIToggle(e.target.checked)}
/>
{t('settings.ai.genericOpenAIEnable')}
</label>
{genericOpenAIEnabled && (
<span className="setting-status-badge success">{t('settings.ai.configured')}</span>
)}
</div>
{genericOpenAIEnabled && (
<div className="generic-openai-settings">
<div className="setting-field">
<label htmlFor="ai-generic-openai-base-url">{t('settings.ai.genericOpenAIBaseUrlLabel')}</label>
<small className="setting-description">{t('settings.ai.genericOpenAIBaseUrlDescription')}</small>
<input
id="ai-generic-openai-base-url"
type="text"
value={genericOpenAIBaseURL}
onChange={(e) => setGenericOpenAIBaseURL(e.target.value)}
placeholder="https://api.example.com/v1"
/>
<button className="secondary" onClick={handleSaveGenericOpenAIBaseURL}>
{t('common.save')}
</button>
</div>
<div className="setting-field">
<label htmlFor="ai-generic-openai-api-key">{t('settings.ai.genericOpenAIApiKeyLabel')}</label>
<small className="setting-description">{t('settings.ai.genericOpenAIApiKeyDescription')}</small>
{hasGenericOpenAIApiKey ? (
<>
<input
id="ai-generic-openai-api-key"
type="text"
value={genericOpenAIApiKeyMasked}
disabled
placeholder={t('settings.ai.genericOpenAIApiKeyConfigured')}
/>
<span className="setting-status-badge success">{t('settings.ai.configured')}</span>
<div className="setting-inline-action">
<button className="text-button" onClick={() => { setHasGenericOpenAIApiKey(false); setGenericOpenAIApiKeyMasked(''); }}>
{t('settings.ai.changeApiKey')}
</button>
</div>
</>
) : (
<>
<input
id="ai-generic-openai-api-key"
type="password"
value={newGenericOpenAIApiKey}
onChange={(e) => setNewGenericOpenAIApiKey(e.target.value)}
placeholder={t('chat.apiKeyPlaceholder')}
/>
<button className="primary" onClick={handleSaveGenericOpenAIApiKey} disabled={!newGenericOpenAIApiKey.trim()}>
{t('chat.apiKeySave')}
</button>
</>
)}
</div>
{genericOpenAIEnabled && genericOpenAIModels.length > 0 && (
<div className="generic-openai-model-capabilities">
<small className="setting-description">{t('settings.ai.genericOpenAICapabilitiesDescription')}</small>
<table className="generic-openai-caps-table">
<thead>
<tr>
<th>{t('settings.ai.genericOpenAICapModel')}</th>
<th>{t('settings.ai.genericOpenAICapTools')}</th>
<th>{t('settings.ai.genericOpenAICapVision')}</th>
<th>{t('settings.ai.genericOpenAICapDisableThinking')}</th>
</tr>
</thead>
<tbody>
{genericOpenAIModels.map(m => {
const caps = genericOpenAICapabilities[m.id] ?? { tools: false, vision: false, disableThinking: false };
return (
<tr key={m.id}>
<td>{m.name}</td>
<td>
<input
type="checkbox"
checked={caps.tools}
onChange={(e) => handleGenericOpenAICapabilityToggle(m.id, 'tools', e.target.checked)}
/>
</td>
<td>
<input
type="checkbox"
checked={caps.vision}
onChange={(e) => handleGenericOpenAICapabilityToggle(m.id, 'vision', e.target.checked)}
/>
</td>
<td>
<input
type="checkbox"
checked={caps.disableThinking}
onChange={(e) => handleGenericOpenAICapabilityToggle(m.id, 'disableThinking', e.target.checked)}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
)}
</SettingRow>
<SettingRow <SettingRow
id="ai-offline" id="ai-offline"
label={t('settings.ai.offlineLabel')} label={t('settings.ai.offlineLabel')}

View File

@@ -848,6 +848,7 @@
"settings.ai.providerMistral": "Mistral", "settings.ai.providerMistral": "Mistral",
"settings.ai.providerOllama": "Ollama (Lokal)", "settings.ai.providerOllama": "Ollama (Lokal)",
"settings.ai.providerLmstudio": "LM Studio (Lokal)", "settings.ai.providerLmstudio": "LM Studio (Lokal)",
"settings.ai.providerGenericOpenAI": "Generischer OpenAI-Endpunkt",
"settings.ai.providerOther": "Andere", "settings.ai.providerOther": "Andere",
"settings.ai.ollamaLabel": "Ollama (Lokale Modelle)", "settings.ai.ollamaLabel": "Ollama (Lokale Modelle)",
"settings.ai.ollamaDescription": "Verbinde dich mit einer lokal laufenden Ollama-Instanz, um lokale KI-Modelle zu verwenden.", "settings.ai.ollamaDescription": "Verbinde dich mit einer lokal laufenden Ollama-Instanz, um lokale KI-Modelle zu verwenden.",
@@ -871,6 +872,23 @@
"settings.ai.lmstudioCapVision": "Vision", "settings.ai.lmstudioCapVision": "Vision",
"settings.toast.lmstudioEnabled": "LM Studio aktiviert", "settings.toast.lmstudioEnabled": "LM Studio aktiviert",
"settings.toast.lmstudioDisabled": "LM Studio deaktiviert", "settings.toast.lmstudioDisabled": "LM Studio deaktiviert",
"settings.ai.genericOpenAILabel": "Generischer OpenAI-kompatibler Endpunkt",
"settings.ai.genericOpenOIDescription": "Konfiguriere einen benutzerdefinierten OpenAI-kompatiblen API-Endpunkt (z.B. vLLM, Ollama-Gateway, LiteLLM). Modelle werden von diesem Endpunkt bezogen.",
"settings.ai.genericOpenAIEnable": "Generischen OpenAI-Endpunkt aktivieren",
"settings.ai.genericOpenAIBaseUrlLabel": "Basis-URL",
"settings.ai.genericOpenAIBaseUrlDescription": "Die Basis-URL des OpenAI-kompatiblen API-Endpunkts (z.B. http://localhost:8080/v1).",
"settings.ai.genericOpenAIApiKeyLabel": "API-Schlüssel",
"settings.ai.genericOpenAIApiKeyDescription": "API-Schlüssel für den Endpunkt. Leer lassen, wenn nicht erforderlich.",
"settings.ai.genericOpenAIApiKeyConfigured": "API-Schlüssel konfiguriert",
"settings.ai.genericOpenAICapabilitiesDescription": "Fähigkeiten für jedes Modell von diesem Endpunkt konfigurieren. Tools für Funktionsaufrufe oder Vision für Bildanalyse aktivieren.",
"settings.ai.genericOpenAICapModel": "Modell",
"settings.ai.genericOpenAICapTools": "Tools",
"settings.ai.genericOpenAICapVision": "Vision",
"settings.ai.genericOpenAICapDisableThinking": "Denkmodus deaktivieren",
"settings.toast.genericOpenAIEnabled": "Generischer OpenAI-Endpunkt aktiviert",
"settings.toast.genericOpenAIDisabled": "Generischer OpenAI-Endpunkt deaktiviert",
"settings.toast.genericOpenAISettingsSaved": "Generische OpenAI-Einstellungen gespeichert",
"settings.toast.genericOpenAISettingsSaveFailed": "Generische OpenAI-Einstellungen konnten nicht gespeichert werden",
"settings.ai.offlineLabel": "Flugmodus", "settings.ai.offlineLabel": "Flugmodus",
"settings.ai.offlineDescription": "Wenn aktiviert, werden nur lokal gehostete Modelle (Ollama, LM Studio) verwendet. Cloud-Anbieter werden deaktiviert.", "settings.ai.offlineDescription": "Wenn aktiviert, werden nur lokal gehostete Modelle (Ollama, LM Studio) verwendet. Cloud-Anbieter werden deaktiviert.",
"settings.ai.offlineEnable": "Flugmodus aktivieren", "settings.ai.offlineEnable": "Flugmodus aktivieren",

View File

@@ -848,6 +848,7 @@
"settings.ai.providerMistral": "Mistral", "settings.ai.providerMistral": "Mistral",
"settings.ai.providerOllama": "Ollama (Local)", "settings.ai.providerOllama": "Ollama (Local)",
"settings.ai.providerLmstudio": "LM Studio (Local)", "settings.ai.providerLmstudio": "LM Studio (Local)",
"settings.ai.providerGenericOpenAI": "Generic OpenAI Endpoint",
"settings.ai.providerOther": "Other", "settings.ai.providerOther": "Other",
"settings.ai.ollamaLabel": "Ollama (Local Models)", "settings.ai.ollamaLabel": "Ollama (Local Models)",
"settings.ai.ollamaDescription": "Connect to a locally running Ollama instance to use local AI models.", "settings.ai.ollamaDescription": "Connect to a locally running Ollama instance to use local AI models.",
@@ -871,6 +872,23 @@
"settings.ai.lmstudioCapVision": "Vision", "settings.ai.lmstudioCapVision": "Vision",
"settings.toast.lmstudioEnabled": "LM Studio enabled", "settings.toast.lmstudioEnabled": "LM Studio enabled",
"settings.toast.lmstudioDisabled": "LM Studio disabled", "settings.toast.lmstudioDisabled": "LM Studio disabled",
"settings.ai.genericOpenAILabel": "Generic OpenAI-Compatible Endpoint",
"settings.ai.genericOpenOIDescription": "Configure a custom OpenAI-compatible API endpoint (e.g., vLLM, Ollama gateway, LiteLLM). Models will be fetched from this endpoint.",
"settings.ai.genericOpenAIEnable": "Enable Generic OpenAI Endpoint",
"settings.ai.genericOpenAIBaseUrlLabel": "Base URL",
"settings.ai.genericOpenAIBaseUrlDescription": "The base URL of the OpenAI-compatible API endpoint (e.g., http://localhost:8080/v1).",
"settings.ai.genericOpenAIApiKeyLabel": "API Key",
"settings.ai.genericOpenAIApiKeyDescription": "API key for the endpoint. Leave empty if not required.",
"settings.ai.genericOpenAIApiKeyConfigured": "API key configured",
"settings.ai.genericOpenAICapabilitiesDescription": "Configure capabilities for each model from this endpoint. Enable tools for function calling or vision for image analysis.",
"settings.ai.genericOpenAICapModel": "Model",
"settings.ai.genericOpenAICapTools": "Tools",
"settings.ai.genericOpenAICapVision": "Vision",
"settings.ai.genericOpenAICapDisableThinking": "Disable Thinking",
"settings.toast.genericOpenAIEnabled": "Generic OpenAI endpoint enabled",
"settings.toast.genericOpenAIDisabled": "Generic OpenAI endpoint disabled",
"settings.toast.genericOpenAISettingsSaved": "Generic OpenAI settings saved",
"settings.toast.genericOpenAISettingsSaveFailed": "Failed to save generic OpenAI settings",
"settings.ai.offlineLabel": "Airplane Mode", "settings.ai.offlineLabel": "Airplane Mode",
"settings.ai.offlineDescription": "When enabled, only locally hosted models (Ollama, LM Studio) are used. Cloud providers are disabled.", "settings.ai.offlineDescription": "When enabled, only locally hosted models (Ollama, LM Studio) are used. Cloud providers are disabled.",
"settings.ai.offlineEnable": "Enable Airplane Mode", "settings.ai.offlineEnable": "Enable Airplane Mode",

View File

@@ -848,6 +848,7 @@
"settings.ai.providerMistral": "Mistral", "settings.ai.providerMistral": "Mistral",
"settings.ai.providerOllama": "Ollama (Local)", "settings.ai.providerOllama": "Ollama (Local)",
"settings.ai.providerLmstudio": "LM Studio (Local)", "settings.ai.providerLmstudio": "LM Studio (Local)",
"settings.ai.providerGenericOpenAI": "Endpoint Genérico OpenAI",
"settings.ai.providerOther": "Otro", "settings.ai.providerOther": "Otro",
"settings.ai.ollamaLabel": "Ollama (Modelos locales)", "settings.ai.ollamaLabel": "Ollama (Modelos locales)",
"settings.ai.ollamaDescription": "Conéctate a una instancia local de Ollama para usar modelos de IA locales.", "settings.ai.ollamaDescription": "Conéctate a una instancia local de Ollama para usar modelos de IA locales.",
@@ -871,6 +872,23 @@
"settings.ai.lmstudioCapVision": "Visión", "settings.ai.lmstudioCapVision": "Visión",
"settings.toast.lmstudioEnabled": "LM Studio activado", "settings.toast.lmstudioEnabled": "LM Studio activado",
"settings.toast.lmstudioDisabled": "LM Studio desactivado", "settings.toast.lmstudioDisabled": "LM Studio desactivado",
"settings.ai.genericOpenAILabel": "Endpoint Genérico Compatible con OpenAI",
"settings.ai.genericOpenOIDescription": "Configura un endpoint de API compatible con OpenAI personalizado (por ejemplo, vLLM, gateway Ollama, LiteLLM). Los modelos se obtendrán de este endpoint.",
"settings.ai.genericOpenAIEnable": "Habilitar Endpoint Genérico OpenAI",
"settings.ai.genericOpenAIBaseUrlLabel": "URL base",
"settings.ai.genericOpenAIBaseUrlDescription": "La URL base del endpoint de API compatible con OpenAI (por ejemplo, http://localhost:8080/v1).",
"settings.ai.genericOpenAIApiKeyLabel": "Clave API",
"settings.ai.genericOpenAIApiKeyDescription": "Clave API para el endpoint. Déjela vacía si no es necesaria.",
"settings.ai.genericOpenAIApiKeyConfigured": "Clave API configurada",
"settings.ai.genericOpenAICapabilitiesDescription": "Configure las capacidades para cada modelo de este endpoint. Habilite herramientas para llamadas de funciones o visión para análisis de imágenes.",
"settings.ai.genericOpenAICapModel": "Modelo",
"settings.ai.genericOpenAICapTools": "Herramientas",
"settings.ai.genericOpenAICapVision": "Visión",
"settings.ai.genericOpenAICapDisableThinking": "Desactivar razonamiento",
"settings.toast.genericOpenAIEnabled": "Endpoint genérico OpenAI habilitado",
"settings.toast.genericOpenAIDisabled": "Endpoint genérico OpenAI deshabilitado",
"settings.toast.genericOpenAISettingsSaved": "Configuración genérica de OpenAI guardada",
"settings.toast.genericOpenAISettingsSaveFailed": "Error al guardar la configuración genérica de OpenAI",
"settings.ai.offlineLabel": "Modo avión", "settings.ai.offlineLabel": "Modo avión",
"settings.ai.offlineDescription": "Cuando está activado, solo se usan modelos alojados localmente (Ollama, LM Studio). Los proveedores en la nube se desactivan.", "settings.ai.offlineDescription": "Cuando está activado, solo se usan modelos alojados localmente (Ollama, LM Studio). Los proveedores en la nube se desactivan.",
"settings.ai.offlineEnable": "Activar modo avión", "settings.ai.offlineEnable": "Activar modo avión",

View File

@@ -848,6 +848,7 @@
"settings.ai.providerMistral": "Mistral", "settings.ai.providerMistral": "Mistral",
"settings.ai.providerOllama": "Ollama (Local)", "settings.ai.providerOllama": "Ollama (Local)",
"settings.ai.providerLmstudio": "LM Studio (Local)", "settings.ai.providerLmstudio": "LM Studio (Local)",
"settings.ai.providerGenericOpenAI": "Point de terminaison OpenAI générique",
"settings.ai.providerOther": "Autre", "settings.ai.providerOther": "Autre",
"settings.ai.ollamaLabel": "Ollama (Modèles locaux)", "settings.ai.ollamaLabel": "Ollama (Modèles locaux)",
"settings.ai.ollamaDescription": "Connectez-vous à une instance Ollama locale pour utiliser des modèles d'IA locaux.", "settings.ai.ollamaDescription": "Connectez-vous à une instance Ollama locale pour utiliser des modèles d'IA locaux.",
@@ -871,6 +872,23 @@
"settings.ai.lmstudioCapVision": "Vision", "settings.ai.lmstudioCapVision": "Vision",
"settings.toast.lmstudioEnabled": "LM Studio activé", "settings.toast.lmstudioEnabled": "LM Studio activé",
"settings.toast.lmstudioDisabled": "LM Studio désactivé", "settings.toast.lmstudioDisabled": "LM Studio désactivé",
"settings.ai.genericOpenAILabel": "Point de terminaison OpenAI générique",
"settings.ai.genericOpenOIDescription": "Configurez un point de terminaison d'API OpenAI compatible personnalisé (par exemple, vLLM, passerelle Ollama, LiteLLM). Les modèles seront récupérés depuis ce point de terminaison.",
"settings.ai.genericOpenAIEnable": "Activer le point de terminaison OpenAI générique",
"settings.ai.genericOpenAIBaseUrlLabel": "URL de base",
"settings.ai.genericOpenAIBaseUrlDescription": "L'URL de base du point de terminaison d'API compatible OpenAI (par exemple, http://localhost:8080/v1).",
"settings.ai.genericOpenAIApiKeyLabel": "Clé API",
"settings.ai.genericOpenAIApiKeyDescription": "Clé API pour le point de terminaison. Laissez vide si non requise.",
"settings.ai.genericOpenAIApiKeyConfigured": "Clé API configurée",
"settings.ai.genericOpenAICapabilitiesDescription": "Configurez les capacités pour chaque modèle de ce point de terminaison. Activez les outils pour l'appel de fonctions ou la vision pour l'analyse d'images.",
"settings.ai.genericOpenAICapModel": "Modèle",
"settings.ai.genericOpenAICapTools": "Outils",
"settings.ai.genericOpenAICapVision": "Vision",
"settings.ai.genericOpenAICapDisableThinking": "Désactiver le raisonnement",
"settings.toast.genericOpenAIEnabled": "Point de terminaison OpenAI générique activé",
"settings.toast.genericOpenAIDisabled": "Point de terminaison OpenAI générique désactivé",
"settings.toast.genericOpenAISettingsSaved": "Paramètres OpenAI génériques enregistrés",
"settings.toast.genericOpenAISettingsSaveFailed": "Échec de l'enregistrement des paramètres OpenAI génériques",
"settings.ai.offlineLabel": "Mode avion", "settings.ai.offlineLabel": "Mode avion",
"settings.ai.offlineDescription": "Lorsqu'il est activé, seuls les modèles hébergés localement (Ollama, LM Studio) sont utilisés. Les fournisseurs cloud sont désactivés.", "settings.ai.offlineDescription": "Lorsqu'il est activé, seuls les modèles hébergés localement (Ollama, LM Studio) sont utilisés. Les fournisseurs cloud sont désactivés.",
"settings.ai.offlineEnable": "Activer le mode avion", "settings.ai.offlineEnable": "Activer le mode avion",

View File

@@ -848,6 +848,7 @@
"settings.ai.providerMistral": "Mistral", "settings.ai.providerMistral": "Mistral",
"settings.ai.providerOllama": "Ollama (Locale)", "settings.ai.providerOllama": "Ollama (Locale)",
"settings.ai.providerLmstudio": "LM Studio (Locale)", "settings.ai.providerLmstudio": "LM Studio (Locale)",
"settings.ai.providerGenericOpenAI": "Endpoint Generico OpenAI",
"settings.ai.providerOther": "Altro", "settings.ai.providerOther": "Altro",
"settings.ai.ollamaLabel": "Ollama (Modelli locali)", "settings.ai.ollamaLabel": "Ollama (Modelli locali)",
"settings.ai.ollamaDescription": "Connettiti a un'istanza Ollama locale per utilizzare modelli IA locali.", "settings.ai.ollamaDescription": "Connettiti a un'istanza Ollama locale per utilizzare modelli IA locali.",
@@ -871,6 +872,23 @@
"settings.ai.lmstudioCapVision": "Visione", "settings.ai.lmstudioCapVision": "Visione",
"settings.toast.lmstudioEnabled": "LM Studio attivato", "settings.toast.lmstudioEnabled": "LM Studio attivato",
"settings.toast.lmstudioDisabled": "LM Studio disattivato", "settings.toast.lmstudioDisabled": "LM Studio disattivato",
"settings.ai.genericOpenAILabel": "Endpoint Generico Compatibile con OpenAI",
"settings.ai.genericOpenOIDescription": "Configura un endpoint API personalizzato compatibile con OpenAI (ad esempio, vLLM, gateway Ollama, LiteLLM). I modelli verranno recuperati da questo endpoint.",
"settings.ai.genericOpenAIEnable": "Abilita Endpoint Generico OpenAI",
"settings.ai.genericOpenAIBaseUrlLabel": "URL di base",
"settings.ai.genericOpenAIBaseUrlDescription": "L'URL di base dell'endpoint API compatibile con OpenAI (ad esempio, http://localhost:8080/v1).",
"settings.ai.genericOpenAIApiKeyLabel": "Chiave API",
"settings.ai.genericOpenAIApiKeyDescription": "Chiave API per l'endpoint. Lascia vuoto se non richiesta.",
"settings.ai.genericOpenAIApiKeyConfigured": "Chiave API configurata",
"settings.ai.genericOpenAICapabilitiesDescription": "Configura le capacità per ogni modello da questo endpoint. Abilita gli strumenti per le chiamate alle funzioni o la visione per l'analisi delle immagini.",
"settings.ai.genericOpenAICapModel": "Modello",
"settings.ai.genericOpenAICapTools": "Strumenti",
"settings.ai.genericOpenAICapVision": "Visione",
"settings.ai.genericOpenAICapDisableThinking": "Disattiva ragionamento",
"settings.toast.genericOpenAIEnabled": "Endpoint generico OpenAI abilitato",
"settings.toast.genericOpenAIDisabled": "Endpoint generico OpenAI disabilitato",
"settings.toast.genericOpenAISettingsSaved": "Impostazioni generiche OpenAI salvate",
"settings.toast.genericOpenAISettingsSaveFailed": "Impossibile salvare le impostazioni generiche OpenAI",
"settings.ai.offlineLabel": "Modalità aereo", "settings.ai.offlineLabel": "Modalità aereo",
"settings.ai.offlineDescription": "Quando attivato, vengono utilizzati solo i modelli ospitati localmente (Ollama, LM Studio). I provider cloud sono disabilitati.", "settings.ai.offlineDescription": "Quando attivato, vengono utilizzati solo i modelli ospitati localmente (Ollama, LM Studio). I provider cloud sono disabilitati.",
"settings.ai.offlineEnable": "Attiva modalità aereo", "settings.ai.offlineEnable": "Attiva modalità aereo",

View File

@@ -140,15 +140,15 @@ describe('ProviderRegistry', () => {
}); });
it('getProviderStatus() reports all providers', () => { it('getProviderStatus() reports all providers', () => {
expect(registry.getProviderStatus()).toEqual({ opencode: false, mistral: false, ollama: false, lmstudio: false, offlineMode: false }); expect(registry.getProviderStatus()).toEqual({ opencode: false, mistral: false, ollama: false, lmstudio: false, genericOpenAI: false, offlineMode: false });
registry.setOpencodeKey('test'); registry.setOpencodeKey('test');
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: false, ollama: false, lmstudio: false, offlineMode: false }); expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: false, ollama: false, lmstudio: false, genericOpenAI: false, offlineMode: false });
registry.setMistralKey('test2'); registry.setMistralKey('test2');
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: false, lmstudio: false, offlineMode: false }); expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: false, lmstudio: false, genericOpenAI: false, offlineMode: false });
registry.setOllamaEnabled(true); registry.setOllamaEnabled(true);
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: true, lmstudio: false, offlineMode: false }); expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: true, lmstudio: false, genericOpenAI: false, offlineMode: false });
registry.setLmstudioEnabled(true); registry.setLmstudioEnabled(true);
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: true, lmstudio: true, offlineMode: false }); expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: true, lmstudio: true, genericOpenAI: false, offlineMode: false });
}); });
it('isProviderKeySet() checks per-provider', () => { it('isProviderKeySet() checks per-provider', () => {

View 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' });
});
});

View 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;
}
});
});

View File

@@ -24,9 +24,11 @@ const secureKeyStoreInstances: Array<Record<string, any>> = [];
// Per-test overrides for SecureKeyStore mock behavior // Per-test overrides for SecureKeyStore mock behavior
let secureKeyStoreRetrieveResult: string | null = 'encrypted-stored-key'; let secureKeyStoreRetrieveResult: string | null = 'encrypted-stored-key';
let secureKeyStoreRetrieveByKey = new Map<string, string | null>();
let secureKeyStoreStoreError: Error | null = null; let secureKeyStoreStoreError: Error | null = null;
let secureKeyStoreRetrieveError: Error | null = null; let secureKeyStoreRetrieveError: Error | null = null;
let secureKeyStoreCleanupError: Error | null = null; let secureKeyStoreCleanupError: Error | null = null;
let chatEngineSettingValues = new Map<string, string | null>();
vi.mock('electron', () => ({ vi.mock('electron', () => ({
BrowserWindow: { BrowserWindow: {
@@ -55,7 +57,7 @@ vi.mock('../../src/main/engine/ChatEngine', () => ({
ChatEngine: class { ChatEngine: class {
constructor() { constructor() {
const instance = { const instance = {
getSetting: vi.fn(async () => null), getSetting: vi.fn(async (key: string) => chatEngineSettingValues.get(key) ?? null),
setSetting: vi.fn(async () => undefined), setSetting: vi.fn(async () => undefined),
deleteSetting: vi.fn(async () => undefined), deleteSetting: vi.fn(async () => undefined),
getSelectedModel: vi.fn(async () => 'gpt-5'), getSelectedModel: vi.fn(async () => 'gpt-5'),
@@ -75,8 +77,11 @@ vi.mock('../../src/main/engine/SecureKeyStore', () => ({
store: vi.fn(async (_key: string, _value: string) => { store: vi.fn(async (_key: string, _value: string) => {
if (secureKeyStoreStoreError) throw secureKeyStoreStoreError; if (secureKeyStoreStoreError) throw secureKeyStoreStoreError;
}), }),
retrieve: vi.fn(async () => { retrieve: vi.fn(async (key: string) => {
if (secureKeyStoreRetrieveError) throw secureKeyStoreRetrieveError; if (secureKeyStoreRetrieveError) throw secureKeyStoreRetrieveError;
if (secureKeyStoreRetrieveByKey.has(key)) {
return secureKeyStoreRetrieveByKey.get(key) ?? null;
}
return secureKeyStoreRetrieveResult; return secureKeyStoreRetrieveResult;
}), }),
remove: vi.fn(async () => undefined), remove: vi.fn(async () => undefined),
@@ -98,9 +103,17 @@ vi.mock('../../src/main/engine/ai/providers', () => ({
getOpencodeKey: vi.fn(() => 'abc12345'), getOpencodeKey: vi.fn(() => 'abc12345'),
setMistralKey: vi.fn(), setMistralKey: vi.fn(),
getMistralKey: vi.fn(() => ''), getMistralKey: vi.fn(() => ''),
setGenericOpenAIEnabled: vi.fn(),
isGenericOpenAIEnabled: vi.fn(() => false),
setGenericOpenAIBaseURL: vi.fn(),
getGenericOpenAIBaseURL: vi.fn(() => ''),
setGenericOpenAIApiKey: vi.fn(),
getGenericOpenAIApiKey: vi.fn(() => ''),
loadGenericOpenAIModelCapabilities: vi.fn(),
registerGenericOpenAIModel: vi.fn(),
isReady: vi.fn(() => true), isReady: vi.fn(() => true),
isProviderKeySet: vi.fn(() => true), isProviderKeySet: vi.fn(() => true),
getProviderStatus: vi.fn(() => ({ opencode: true, mistral: false })), getProviderStatus: vi.fn(() => ({ opencode: true, mistral: false, ollama: false, lmstudio: false, genericOpenAI: false, offlineMode: false })),
resolveModel: vi.fn(), resolveModel: vi.fn(),
getAvailableModels: vi.fn(async () => []), getAvailableModels: vi.fn(async () => []),
validateOpencodeKey: vi.fn(async () => ({ isValid: true, models: [] })), validateOpencodeKey: vi.fn(async () => ({ isValid: true, models: [] })),
@@ -141,9 +154,11 @@ describe('chatHandlers keychain integration', () => {
providerRegistryInstances.length = 0; providerRegistryInstances.length = 0;
secureKeyStoreInstances.length = 0; secureKeyStoreInstances.length = 0;
secureKeyStoreRetrieveResult = 'encrypted-stored-key'; secureKeyStoreRetrieveResult = 'encrypted-stored-key';
secureKeyStoreRetrieveByKey = new Map();
secureKeyStoreStoreError = null; secureKeyStoreStoreError = null;
secureKeyStoreRetrieveError = null; secureKeyStoreRetrieveError = null;
secureKeyStoreCleanupError = null; secureKeyStoreCleanupError = null;
chatEngineSettingValues = new Map();
vi.resetModules(); vi.resetModules();
}); });
@@ -282,6 +297,42 @@ describe('chatHandlers keychain integration', () => {
expect(registry.setOpencodeKey).toHaveBeenCalledWith('encrypted-stored-key'); expect(registry.setOpencodeKey).toHaveBeenCalledWith('encrypted-stored-key');
}); });
it('restores generic endpoint settings from storage on init', async () => {
chatEngineSettingValues = new Map([
['generic_openai_enabled', 'true'],
['generic_openai_base_url', 'http://localhost:4000/v1'],
['generic_openai_model_capabilities', JSON.stringify({
'custom-model': { tools: true, vision: false },
})],
['generic_openai_known_model_ids', JSON.stringify(['custom-model'])],
]);
secureKeyStoreRetrieveByKey = new Map([
['opencode_api_key', 'encrypted-stored-key'],
['mistral_api_key', null],
['generic_openai_api_key', 'generic-secret'],
]);
const mod = await import('../../src/main/ipc/chatHandlers');
const mockBundle = { postEngine: {}, mediaEngine: {}, postMediaEngine: {} };
mod.initializeChatHandlers(() => mainWindowMock as never, mockBundle as any);
mod.registerChatHandlers();
const handler = registeredHandlers.get('chat:checkReady');
await handler!(undefined);
const registry = providerRegistryInstances[0];
expect(registry.setGenericOpenAIEnabled).toHaveBeenCalledWith(true);
expect(registry.setGenericOpenAIBaseURL).toHaveBeenCalledWith('http://localhost:4000/v1');
expect(registry.setGenericOpenAIApiKey).toHaveBeenCalledWith('generic-secret');
expect(registry.loadGenericOpenAIModelCapabilities).toHaveBeenCalledWith({
'custom-model': { tools: true, vision: false },
});
expect(registry.registerGenericOpenAIModel).toHaveBeenCalledWith('custom-model');
const keyStore = secureKeyStoreInstances[0];
expect(keyStore.retrieve).toHaveBeenCalledWith('generic_openai_api_key');
});
it('returns error and rolls back in-memory key when store() throws on chat:setApiKey', async () => { it('returns error and rolls back in-memory key when store() throws on chat:setApiKey', async () => {
secureKeyStoreStoreError = new Error('encryption unavailable'); secureKeyStoreStoreError = new Error('encryption unavailable');

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react';
import { SettingsView } from '../../../src/renderer/components/SettingsView/SettingsView'; import { SettingsView } from '../../../src/renderer/components/SettingsView/SettingsView';
import { useAppStore } from '../../../src/renderer/store'; import { useAppStore } from '../../../src/renderer/store';
@@ -24,6 +24,8 @@ describe('MCPAgentButton uninstall', () => {
app: { getDefaultProjectPath: vi.fn().mockResolvedValue('/repo') }, app: { getDefaultProjectPath: vi.fn().mockResolvedValue('/repo') },
meta: { meta: {
getCategories: vi.fn().mockResolvedValue(['article']), getCategories: vi.fn().mockResolvedValue(['article']),
getPublishingPreferences: vi.fn().mockResolvedValue(null),
setPublishingPreferences: vi.fn().mockResolvedValue({}),
getProjectMetadata: vi.fn().mockResolvedValue({ getProjectMetadata: vi.fn().mockResolvedValue({
maxPostsPerPage: 75, maxPostsPerPage: 75,
publicUrl: 'https://example.com', publicUrl: 'https://example.com',
@@ -36,6 +38,17 @@ describe('MCPAgentButton uninstall', () => {
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }), getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }), getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
getMistralApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }), getMistralApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getOllamaEnabled: vi.fn().mockResolvedValue(false),
getLmstudioEnabled: vi.fn().mockResolvedValue(false),
getGenericOpenAIEnabled: vi.fn().mockResolvedValue(false),
getGenericOpenAIBaseURL: vi.fn().mockResolvedValue(''),
getGenericOpenAIApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getGenericOpenAIModelCapabilities: vi.fn().mockResolvedValue({}),
getGenericOpenAIModels: vi.fn().mockResolvedValue([]),
getOfflineMode: vi.fn().mockResolvedValue(false),
getOfflineChatModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
getOfflineTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
getOfflineImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
getTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-haiku-4-5' }), getTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-haiku-4-5' }),
getImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-sonnet-4-5' }), getImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-sonnet-4-5' }),
getModelCatalog: vi.fn().mockResolvedValue({ success: true, entries: [] }), getModelCatalog: vi.fn().mockResolvedValue({ success: true, entries: [] }),
@@ -114,6 +127,8 @@ describe('SettingsView Diff Preferences', () => {
meta: { meta: {
...(window as any).electronAPI?.meta, ...(window as any).electronAPI?.meta,
getCategories: vi.fn().mockResolvedValue(['article', 'picture', 'aside', 'page']), getCategories: vi.fn().mockResolvedValue(['article', 'picture', 'aside', 'page']),
getPublishingPreferences: vi.fn().mockResolvedValue(null),
setPublishingPreferences: vi.fn().mockResolvedValue({}),
getProjectMetadata: vi.fn().mockResolvedValue({ getProjectMetadata: vi.fn().mockResolvedValue({
maxPostsPerPage: 75, maxPostsPerPage: 75,
publicUrl: 'https://example.com', publicUrl: 'https://example.com',
@@ -131,6 +146,17 @@ describe('SettingsView Diff Preferences', () => {
getSystemPrompt: vi.fn().mockResolvedValue({ success: true, prompt: '' }), getSystemPrompt: vi.fn().mockResolvedValue({ success: true, prompt: '' }),
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }), getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }), getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
getOllamaEnabled: vi.fn().mockResolvedValue(false),
getLmstudioEnabled: vi.fn().mockResolvedValue(false),
getGenericOpenAIEnabled: vi.fn().mockResolvedValue(false),
getGenericOpenAIBaseURL: vi.fn().mockResolvedValue(''),
getGenericOpenAIApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getGenericOpenAIModelCapabilities: vi.fn().mockResolvedValue({}),
getGenericOpenAIModels: vi.fn().mockResolvedValue([]),
getOfflineMode: vi.fn().mockResolvedValue(false),
getOfflineChatModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
getOfflineTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
getOfflineImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
}, },
templates: { templates: {
...(window as any).electronAPI?.templates, ...(window as any).electronAPI?.templates,
@@ -395,3 +421,101 @@ describe('SettingsView Diff Preferences', () => {
); );
}); });
}); });
describe('SettingsView generic endpoint refresh', () => {
beforeEach(() => {
vi.clearAllMocks();
useAppStore.setState({
activeProject: {
id: 'project-1',
name: 'Test Project',
slug: 'test-project',
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
gitDiffPreferences: {
wordWrap: true,
viewStyle: 'inline',
hideUnchangedRegions: false,
},
});
(window as any).electronAPI = {
...(window as any).electronAPI,
app: {
...(window as any).electronAPI?.app,
getDefaultProjectPath: vi.fn().mockResolvedValue('/repo/path'),
},
meta: {
...(window as any).electronAPI?.meta,
getCategories: vi.fn().mockResolvedValue(['article']),
getPublishingPreferences: vi.fn().mockResolvedValue(null),
setPublishingPreferences: vi.fn().mockResolvedValue({}),
getProjectMetadata: vi.fn().mockResolvedValue({
maxPostsPerPage: 75,
publicUrl: 'https://example.com',
categorySettings: { article: { renderInLists: true, showTitle: true } },
}),
updateProjectMetadata: vi.fn().mockResolvedValue({}),
},
chat: {
...(window as any).electronAPI?.chat,
getSystemPrompt: vi.fn().mockResolvedValue({ success: true, prompt: '' }),
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
getMistralApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
getImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
getModelCatalog: vi.fn().mockResolvedValue({ success: true, entries: [] }),
getOllamaEnabled: vi.fn().mockResolvedValue(false),
getLmstudioEnabled: vi.fn().mockResolvedValue(false),
getGenericOpenAIEnabled: vi.fn().mockResolvedValue(true),
getGenericOpenAIBaseURL: vi.fn().mockResolvedValue('http://localhost:4000/v1'),
getGenericOpenAIApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getGenericOpenAIModelCapabilities: vi.fn().mockResolvedValue({}),
getGenericOpenAIModels: vi.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 'generic-model', name: 'Generic Model' }]),
setGenericOpenAIBaseURL: vi.fn().mockResolvedValue({ success: true }),
getOfflineMode: vi.fn().mockResolvedValue(false),
getOfflineChatModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
getOfflineTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
getOfflineImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: null }),
},
templates: {
...(window as any).electronAPI?.templates,
getEnabledByKind: vi.fn().mockResolvedValue([]),
},
projects: {
...(window as any).electronAPI?.projects,
update: vi.fn().mockResolvedValue({}),
},
mcp: {
...(window as any).electronAPI?.mcp,
getAgents: vi.fn().mockResolvedValue([]),
isConfigured: vi.fn().mockResolvedValue(false),
getPort: vi.fn().mockResolvedValue(4124),
},
};
});
it('reloads generic models after saving the generic endpoint base URL', async () => {
render(<SettingsView />);
const baseUrlInput = await screen.findByLabelText(/base url/i);
const field = baseUrlInput.closest('.setting-field');
expect(field).not.toBeNull();
const saveButton = within(field as HTMLElement).getByRole('button', { name: /save/i });
await act(async () => {
fireEvent.click(saveButton);
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect((window as any).electronAPI.chat.getAvailableModels).toHaveBeenCalledTimes(2);
expect((window as any).electronAPI.chat.getGenericOpenAIModels).toHaveBeenCalledTimes(2);
});
});