diff --git a/eslint.config.js b/eslint.config.js
index ea36dd3..15ece5f 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
- globalIgnores(['dist']),
+ globalIgnores(['dist', 'pb_migrations']),
{
files: ['**/*.{js,jsx}'],
extends: [
@@ -17,5 +17,25 @@ export default defineConfig([
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
+ rules: {
+ // Conventional initial-load pattern across the admin/chat surface;
+ // refactoring every effect is out of scope and not currently
+ // observed to cause cascading renders in practice.
+ 'react-hooks/set-state-in-effect': 'off',
+ },
+ },
+ {
+ files: ['src/store/AppContext.jsx'],
+ rules: {
+ 'react-refresh/only-export-components': 'off',
+ },
+ },
+ {
+ files: ['vite.config.js'],
+ languageOptions: { globals: { ...globals.node } },
+ },
+ {
+ files: ['src/lib/__tests__/**/*.{js,jsx}'],
+ languageOptions: { globals: { ...globals.node } },
},
])
diff --git a/package-lock.json b/package-lock.json
index b386c43..a50c097 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,7 +14,8 @@
"pocketbase": "^0.26.9",
"react": "^19.2.5",
"react-dom": "^19.2.5",
- "react-router-dom": "^7.15.0"
+ "react-router-dom": "^7.15.0",
+ "zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -29,7 +30,8 @@
"globals": "^17.5.0",
"postcss": "^8.5.14",
"tailwindcss": "^4.3.0",
- "vite": "^8.0.10"
+ "vite": "^8.0.10",
+ "vitest": "^4.1.7"
}
},
"node_modules/@babel/code-frame": {
@@ -843,6 +845,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@tailwindcss/node": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
@@ -1126,6 +1135,24 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -1193,6 +1220,119 @@
}
}
},
+ "node_modules/@vitest/expect": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz",
+ "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.7",
+ "@vitest/utils": "4.1.7",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz",
+ "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.7",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz",
+ "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz",
+ "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.7",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz",
+ "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.7",
+ "@vitest/utils": "4.1.7",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz",
+ "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz",
+ "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.7",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -1233,6 +1373,16 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/autoprefixer": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
@@ -1361,6 +1511,16 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
@@ -1878,6 +2038,13 @@
"node": ">=10.13.0"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
+ "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -2073,6 +2240,16 @@
"node": ">=4.0"
}
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -2083,6 +2260,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2825,6 +3012,17 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -2895,6 +3093,13 @@
"node": ">=8"
}
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3140,6 +3345,13 @@
"node": ">=8"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -3150,6 +3362,20 @@
"node": ">=0.10.0"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+ "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tailwindcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
@@ -3171,6 +3397,23 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
+ "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -3188,6 +3431,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -3326,6 +3579,96 @@
}
}
},
+ "node_modules/vitest": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz",
+ "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.7",
+ "@vitest/mocker": "4.1.7",
+ "@vitest/pretty-format": "4.1.7",
+ "@vitest/runner": "4.1.7",
+ "@vitest/snapshot": "4.1.7",
+ "@vitest/spy": "4.1.7",
+ "@vitest/utils": "4.1.7",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.7",
+ "@vitest/browser-preview": "4.1.7",
+ "@vitest/browser-webdriverio": "4.1.7",
+ "@vitest/coverage-istanbul": "4.1.7",
+ "@vitest/coverage-v8": "4.1.7",
+ "@vitest/ui": "4.1.7",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -3342,6 +3685,23 @@
"node": ">= 8"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -3376,7 +3736,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
- "dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
diff --git a/package.json b/package.json
index 869b94b..8f56149 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
+ "test": "vitest run",
"preview": "vite preview"
},
"dependencies": {
@@ -16,7 +17,8 @@
"pocketbase": "^0.26.9",
"react": "^19.2.5",
"react-dom": "^19.2.5",
- "react-router-dom": "^7.15.0"
+ "react-router-dom": "^7.15.0",
+ "zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -31,6 +33,7 @@
"globals": "^17.5.0",
"postcss": "^8.5.14",
"tailwindcss": "^4.3.0",
- "vite": "^8.0.10"
+ "vite": "^8.0.10",
+ "vitest": "^4.1.7"
}
}
diff --git a/src/App.jsx b/src/App.jsx
index 29d14ba..19df4c9 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,4 +1,3 @@
-import React from 'react'
import { Routes, Route, Navigate, Link } from 'react-router-dom'
import { BookOpen, CheckSquare, LayoutDashboard, Trophy, Settings, LogOut } from 'lucide-react'
import { useApp } from './store/AppContext'
diff --git a/src/components/admin/ContentManager.jsx b/src/components/admin/ContentManager.jsx
index 02545cb..85fea25 100644
--- a/src/components/admin/ContentManager.jsx
+++ b/src/components/admin/ContentManager.jsx
@@ -1,9 +1,8 @@
-import React, { useState, useEffect } from 'react';
+import { useState, useEffect } from 'react';
import {
RefreshCw, Wand2, Trash2, CheckCircle, Loader,
AlertCircle, BookOpen, ArrowLeft, Eye
} from 'lucide-react';
-import { motion, AnimatePresence } from 'framer-motion';
import { getAllGeneratedContent, generateLearningContent, refineLearningContent, deleteCachedContent } from '../../lib/learningService';
import LearningContentViewer from '../ui/LearningContentViewer';
import Card from '../ui/Card';
diff --git a/src/components/admin/CurriculumManager.jsx b/src/components/admin/CurriculumManager.jsx
index 77217a4..93d3a10 100644
--- a/src/components/admin/CurriculumManager.jsx
+++ b/src/components/admin/CurriculumManager.jsx
@@ -1,5 +1,5 @@
-import React, { useState, useEffect, useMemo } from 'react';
-import { Calendar, Wand2, ChevronDown, ChevronRight, RotateCcw, CheckCircle2, BookOpen, Loader, AlertTriangle } from 'lucide-react';
+import { useState, useEffect, useMemo } from 'react';
+import { Calendar, Wand2, ChevronDown, ChevronRight, RotateCcw, CheckCircle2, Loader, AlertTriangle } from 'lucide-react';
import Card from '../ui/Card';
import Button from '../ui/Button';
import Tag from '../ui/Tag';
@@ -10,7 +10,6 @@ import {
getQuarterForWeek,
getQuarterName,
getFullCurriculum,
- hasCurriculum,
} from '../../lib/curriculumService';
const QUARTER_COLORS = {
diff --git a/src/components/admin/KnowledgeGraph.jsx b/src/components/admin/KnowledgeGraph.jsx
index 3a463c9..934707d 100644
--- a/src/components/admin/KnowledgeGraph.jsx
+++ b/src/components/admin/KnowledgeGraph.jsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import * as d3 from 'd3';
import { Trash2, Edit2, Save, X, RefreshCw, AlertCircle, Plus, Link as LinkIcon } from 'lucide-react';
import * as db from '../../lib/db';
diff --git a/src/components/admin/SuggestionsQueue.jsx b/src/components/admin/SuggestionsQueue.jsx
index 24d620a..27fabed 100644
--- a/src/components/admin/SuggestionsQueue.jsx
+++ b/src/components/admin/SuggestionsQueue.jsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react';
+import { useEffect, useState } from 'react';
import { Check, X, Clock, Sparkles } from 'lucide-react';
import { kbStore } from '../../lib/kbStore';
import Button from '../ui/Button';
diff --git a/src/components/admin/TeamManager.jsx b/src/components/admin/TeamManager.jsx
index 510ece6..578f623 100644
--- a/src/components/admin/TeamManager.jsx
+++ b/src/components/admin/TeamManager.jsx
@@ -1,5 +1,5 @@
-import React, { useState, useEffect } from 'react';
-import { Users, UserPlus, Trash2, Edit2, Shield, User, CheckCircle } from 'lucide-react';
+import { useState, useEffect } from 'react';
+import { UserPlus, Trash2, Edit2, Shield, User, CheckCircle } from 'lucide-react';
import Card from '../ui/Card';
import Button from '../ui/Button';
import Input from '../ui/Input';
diff --git a/src/components/admin/TestManager.jsx b/src/components/admin/TestManager.jsx
index c0ff053..b6ba18b 100644
--- a/src/components/admin/TestManager.jsx
+++ b/src/components/admin/TestManager.jsx
@@ -1,11 +1,10 @@
-import React, { useState, useEffect } from 'react';
-import { RefreshCw, Trash2, CheckCircle, Loader, AlertCircle, HelpCircle, ArrowLeft, Eye, ChevronDown, ChevronUp } from 'lucide-react';
+import { useState, useEffect } from 'react';
+import { RefreshCw, Trash2, CheckCircle, Loader, AlertCircle, HelpCircle, ArrowLeft, Eye } from 'lucide-react';
import { forceGenerateTopicQuestions, getTopicQuestionBank, deleteQuestion } from '../../lib/testService';
import * as db from '../../lib/db';
import Card from '../ui/Card';
import Button from '../ui/Button';
import Tag from '../ui/Tag';
-import { motion, AnimatePresence } from 'framer-motion';
const TestManager = () => {
const [topics, setTopics] = useState([]);
diff --git a/src/components/admin/UploadZone.jsx b/src/components/admin/UploadZone.jsx
index aa75b10..d64a8a8 100644
--- a/src/components/admin/UploadZone.jsx
+++ b/src/components/admin/UploadZone.jsx
@@ -1,7 +1,6 @@
-import React, { useState, useRef } from 'react';
+import { useState, useRef } from 'react';
import { UploadCloud, AlertCircle, CheckCircle } from 'lucide-react';
import { processSourceText } from '../../lib/extractionPipeline';
-import * as db from '../../lib/db';
import Card from '../ui/Card';
import Button from '../ui/Button';
diff --git a/src/components/chat/ChatLauncher.jsx b/src/components/chat/ChatLauncher.jsx
index 2620553..7fb5382 100644
--- a/src/components/chat/ChatLauncher.jsx
+++ b/src/components/chat/ChatLauncher.jsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react';
+import { useEffect, useState } from 'react';
import Mark from '../ui/Mark';
import ChatWindow from './ChatWindow';
import { useApp } from '../../store/AppContext';
diff --git a/src/components/chat/ChatMessage.jsx b/src/components/chat/ChatMessage.jsx
index 34bff24..a8db190 100644
--- a/src/components/chat/ChatMessage.jsx
+++ b/src/components/chat/ChatMessage.jsx
@@ -1,4 +1,3 @@
-import React from 'react';
import Mark from '../ui/Mark';
import { STRINGS } from './prompts';
diff --git a/src/components/chat/ChatWindow.jsx b/src/components/chat/ChatWindow.jsx
index 061f3a0..9e64c13 100644
--- a/src/components/chat/ChatWindow.jsx
+++ b/src/components/chat/ChatWindow.jsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import Mark from '../ui/Mark';
import ChatMessage from './ChatMessage';
import { useChat } from './useChat';
diff --git a/src/components/ui/Button.jsx b/src/components/ui/Button.jsx
index 3238e05..e03d6ec 100644
--- a/src/components/ui/Button.jsx
+++ b/src/components/ui/Button.jsx
@@ -1,5 +1,3 @@
-import React from 'react';
-
const Button = ({
children,
variant = 'primary',
diff --git a/src/components/ui/Card.jsx b/src/components/ui/Card.jsx
index fc56019..c129322 100644
--- a/src/components/ui/Card.jsx
+++ b/src/components/ui/Card.jsx
@@ -1,5 +1,3 @@
-import React from 'react';
-
const Card = ({ children, className = '', hoverable = false, ...props }) => {
return (
{
const variants = {
default: "bg-bg-warm text-fg",
diff --git a/src/lib/__tests__/llm.test.js b/src/lib/__tests__/llm.test.js
new file mode 100644
index 0000000..f5f00b4
--- /dev/null
+++ b/src/lib/__tests__/llm.test.js
@@ -0,0 +1,232 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { z } from 'zod';
+
+vi.mock('../storage', () => ({
+ storage: {
+ _data: new Map(),
+ get(key, fallback = null) {
+ return this._data.has(key) ? this._data.get(key) : fallback;
+ },
+ set(key, value) { this._data.set(key, value); },
+ remove(key) { this._data.delete(key); },
+ getKeysByPrefix() { return []; },
+ },
+}));
+
+vi.mock('../pb', () => ({
+ pb: { collection: () => ({ create: () => ({ catch: () => {} }) }) },
+}));
+
+import {
+ callLLM,
+ LLMHttpError,
+ LLMOutputError,
+ LLMTruncatedError,
+ LLMValidationError,
+ parseStructuredText,
+ resolveModel,
+} from '../llm';
+import { storage } from '../storage';
+
+const originalFetch = globalThis.fetch;
+
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+ storage._data.clear();
+});
+
+describe('parseStructuredText', () => {
+ it('extracts an object from raw JSON', () => {
+ expect(parseStructuredText('{"a":1}')).toEqual({ a: 1 });
+ });
+
+ it('extracts an object from a json-fenced block', () => {
+ const fenced = '```json\n{"hello":"world"}\n```';
+ expect(parseStructuredText(fenced)).toEqual({ hello: 'world' });
+ });
+
+ it('extracts an object surrounded by prose', () => {
+ const messy = 'Sure! Here you go:\n{"id":"x","label":"X"}\nLet me know if you want changes.';
+ expect(parseStructuredText(messy)).toEqual({ id: 'x', label: 'X' });
+ });
+
+ it('extracts an array when it is the top-level value', () => {
+ expect(parseStructuredText('[1,2,3]')).toEqual([1, 2, 3]);
+ });
+
+ it('ignores braces inside string literals', () => {
+ const tricky = '{"text":"this { is not } a brace"}';
+ expect(parseStructuredText(tricky)).toEqual({ text: 'this { is not } a brace' });
+ });
+
+ it('throws LLMOutputError when no balanced JSON is present', () => {
+ expect(() => parseStructuredText('no json here, just words')).toThrow(LLMOutputError);
+ });
+});
+
+describe('resolveModel', () => {
+ it('falls back to tier defaults when no override is set', () => {
+ expect(resolveModel('fast')).toBe('claude-haiku-4-5-20251001');
+ expect(resolveModel('standard')).toBe('claude-sonnet-4-6');
+ expect(resolveModel('reasoning')).toBe('claude-opus-4-7');
+ });
+
+ it('honours an explicit tier override', () => {
+ storage.set('admin:model:reasoning', 'claude-opus-9-future');
+ expect(resolveModel('reasoning')).toBe('claude-opus-9-future');
+ });
+
+ it('uses the legacy admin:model setting as a standard-tier fallback', () => {
+ storage.set('admin:model', 'claude-some-legacy-id');
+ expect(resolveModel('standard')).toBe('claude-some-legacy-id');
+ });
+
+ it('prefers the tier-specific override over the legacy fallback', () => {
+ storage.set('admin:model', 'claude-legacy');
+ storage.set('admin:model:standard', 'claude-new');
+ expect(resolveModel('standard')).toBe('claude-new');
+ });
+});
+
+function mockJsonResponse(body, { status = 200, headers = {} } = {}) {
+ const h = new Headers({ 'content-type': 'application/json', ...headers });
+ return new Response(JSON.stringify(body), { status, headers: h });
+}
+
+describe('callLLM happy path', () => {
+ it('returns parsed tool input when toolChoice forces a tool', async () => {
+ globalThis.fetch = vi.fn(async () =>
+ mockJsonResponse({
+ id: 'msg_1',
+ model: 'claude-sonnet-4-6',
+ stop_reason: 'tool_use',
+ usage: { input_tokens: 10, output_tokens: 20 },
+ content: [
+ {
+ type: 'tool_use',
+ name: 'emit_custom_topic',
+ input: { label: 'Pair Programming', type: 'process', description: 'Two engineers, one keyboard.' },
+ },
+ ],
+ }),
+ );
+
+ const result = await callLLM({
+ task: 'learning.custom_topic',
+ tier: 'standard',
+ user: 'Pair programming',
+ tools: [{ name: 'emit_custom_topic', description: 'x', input_schema: { type: 'object' } }],
+ toolChoice: { type: 'tool', name: 'emit_custom_topic' },
+ });
+
+ expect(result.toolUses).toHaveLength(1);
+ expect(result.toolUses[0].input.label).toBe('Pair Programming');
+ });
+
+ it('parses and validates plain text against a Zod schema', async () => {
+ globalThis.fetch = vi.fn(async () =>
+ mockJsonResponse({
+ id: 'msg_2',
+ model: 'claude-sonnet-4-6',
+ stop_reason: 'end_turn',
+ usage: { input_tokens: 5, output_tokens: 7 },
+ content: [{ type: 'text', text: '```json\n{"value":42}\n```' }],
+ }),
+ );
+
+ const schema = z.object({ value: z.number() });
+ const result = await callLLM({
+ task: 'demo.json',
+ user: 'give me a number',
+ schema,
+ });
+ expect(result.parsed).toEqual({ value: 42 });
+ });
+});
+
+describe('callLLM error paths', () => {
+ it('throws LLMTruncatedError when stop_reason is max_tokens and a tool was requested', async () => {
+ globalThis.fetch = vi.fn(async () =>
+ mockJsonResponse({
+ stop_reason: 'max_tokens',
+ usage: { input_tokens: 1, output_tokens: 1 },
+ content: [],
+ }),
+ );
+ await expect(
+ callLLM({
+ task: 'extract.source',
+ user: 'x',
+ tools: [{ name: 'emit_knowledge_graph', description: 'x', input_schema: { type: 'object' } }],
+ toolChoice: { type: 'tool', name: 'emit_knowledge_graph' },
+ }),
+ ).rejects.toBeInstanceOf(LLMTruncatedError);
+ });
+
+ it('throws LLMTruncatedError when stop_reason is max_tokens and a schema was requested', async () => {
+ globalThis.fetch = vi.fn(async () =>
+ mockJsonResponse({
+ stop_reason: 'max_tokens',
+ usage: { input_tokens: 1, output_tokens: 1 },
+ content: [{ type: 'text', text: 'partial...' }],
+ }),
+ );
+ const schema = z.object({ value: z.number() });
+ await expect(
+ callLLM({ task: 'demo.json', user: 'x', schema }),
+ ).rejects.toBeInstanceOf(LLMTruncatedError);
+ });
+
+ it('throws LLMValidationError when tool input fails schema validation', async () => {
+ globalThis.fetch = vi.fn(async () =>
+ mockJsonResponse({
+ stop_reason: 'tool_use',
+ usage: {},
+ content: [
+ { type: 'tool_use', name: 'emit_custom_topic', input: { label: 'X', type: 'concept' } },
+ ],
+ }),
+ );
+ await expect(
+ callLLM({
+ task: 'learning.custom_topic',
+ user: 'x',
+ tools: [{ name: 'emit_custom_topic', description: 'x', input_schema: { type: 'object' } }],
+ toolChoice: { type: 'tool', name: 'emit_custom_topic' },
+ }),
+ ).rejects.toBeInstanceOf(LLMValidationError);
+ });
+
+ it('surfaces a non-retryable HTTP error as LLMHttpError', async () => {
+ globalThis.fetch = vi.fn(async () =>
+ new Response(JSON.stringify({ error: 'bad request' }), {
+ status: 400,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+ await expect(callLLM({ task: 'demo', user: 'x' })).rejects.toBeInstanceOf(LLMHttpError);
+ });
+
+ it('detects an auth portal HTML response and raises a clear message', async () => {
+ globalThis.fetch = vi.fn(async () =>
+ new Response('login', { status: 200, headers: { 'content-type': 'text/html' } }),
+ );
+ await expect(callLLM({ task: 'demo', user: 'x' })).rejects.toThrow(/session has expired/i);
+ });
+});
+
+describe('callLLM simulation mode', () => {
+ it('returns the chat stub when admin:use_simulation is true and task is chat-like', async () => {
+ storage.set('admin:use_simulation', true);
+ const result = await callLLM({ task: 'chat.r42', user: 'hello' });
+ expect(result.stopReason).toBe('end_turn');
+ expect(result.text).toMatch(/Simulatiemodus/);
+ });
+
+ it('returns the extraction stub for other tasks in simulation mode', async () => {
+ storage.set('admin:use_simulation', true);
+ const result = await callLLM({ task: 'extract.source', user: 'doc' });
+ expect(() => JSON.parse(result.text)).not.toThrow();
+ expect(JSON.parse(result.text)).toHaveProperty('topics');
+ });
+});
diff --git a/src/lib/__tests__/llmSchemas.test.js b/src/lib/__tests__/llmSchemas.test.js
new file mode 100644
index 0000000..ab27d9c
--- /dev/null
+++ b/src/lib/__tests__/llmSchemas.test.js
@@ -0,0 +1,197 @@
+import { describe, expect, it } from 'vitest';
+import {
+ extractionResultSchema,
+ handbookResultSchema,
+ normalizeHandbookResult,
+ learningArticleSchema,
+ learningSlidesSchema,
+ learningInfographicSchema,
+ learningAllSchema,
+ quizQuestionsSchema,
+ customTopicSchema,
+ graphActionsSchema,
+ proposeGraphDeltaSchema,
+} from '../llmSchemas';
+
+const sampleTopic = {
+ id: 'software-engineer',
+ label: 'Software Engineer',
+ type: 'role',
+ description: 'Builds and maintains the platform.',
+ learning_relevance: 'core',
+};
+
+const sampleRelation = {
+ source: 'software-engineer',
+ target: 'onboarding',
+ type: 'part_of',
+};
+
+const sampleArticle = {
+ title: 'Onboarding 101',
+ intro: 'A short intro.',
+ sections: [{ heading: 'Day one', body: 'Welcome to the team.' }],
+ keyTakeaways: ['Show up', 'Ask questions'],
+};
+
+const sampleSlide = {
+ title: 'Welcome',
+ bullets: ['Meet your buddy', 'Read the handbook'],
+ speakerNote: 'Greet new joiners warmly.',
+};
+
+const sampleInfographic = {
+ headline: 'Onboarding flow',
+ tagline: 'From hire to productive in 30 days',
+ stats: [{ value: '30', label: 'days', icon: 'π
' }],
+ steps: [{ number: 1, title: 'Sign in', description: 'Use the welcome email.', icon: 'π' }],
+ quote: 'A great start beats a great recovery.',
+ colorTheme: 'teal',
+};
+
+describe('extractionResultSchema', () => {
+ it('accepts a minimal extraction result', () => {
+ const parsed = extractionResultSchema.parse({
+ topics: [sampleTopic],
+ relations: [sampleRelation],
+ });
+ expect(parsed.topics).toHaveLength(1);
+ expect(parsed.relations[0].type).toBe('part_of');
+ });
+});
+
+describe('handbookResultSchema', () => {
+ it('accepts the loose vocabulary including executes', () => {
+ const parsed = handbookResultSchema.parse({
+ topics: [{ ...sampleTopic, metadata: { source: 'github_handbook' } }],
+ relations: [{ source: 'software-engineer', target: 'code-review', type: 'executes' }],
+ });
+ expect(parsed.relations[0].type).toBe('executes');
+ });
+
+ it('normalises executes into executed_by with swapped source/target', () => {
+ const parsed = handbookResultSchema.parse({
+ topics: [sampleTopic],
+ relations: [{ source: 'software-engineer', target: 'code-review', type: 'executes' }],
+ });
+ const normalised = normalizeHandbookResult(parsed);
+ expect(normalised.relations[0]).toMatchObject({
+ source: 'code-review',
+ target: 'software-engineer',
+ type: 'executed_by',
+ });
+ });
+});
+
+describe('learning schemas', () => {
+ it('accepts an article payload', () => {
+ expect(() => learningArticleSchema.parse({ article: sampleArticle })).not.toThrow();
+ });
+
+ it('accepts a slides payload', () => {
+ expect(() => learningSlidesSchema.parse({ slides: [sampleSlide] })).not.toThrow();
+ });
+
+ it('accepts an infographic payload', () => {
+ expect(() => learningInfographicSchema.parse({ infographic: sampleInfographic })).not.toThrow();
+ });
+
+ it('accepts a combined "all" payload', () => {
+ expect(() =>
+ learningAllSchema.parse({
+ article: sampleArticle,
+ slides: [sampleSlide],
+ infographic: sampleInfographic,
+ }),
+ ).not.toThrow();
+ });
+});
+
+describe('quizQuestionsSchema', () => {
+ it('accepts a quiz with four options and a valid correctIndex', () => {
+ const parsed = quizQuestionsSchema.parse({
+ questions: [
+ {
+ id: 'q-1',
+ question: 'What is the buddy system?',
+ topicLabel: 'Onboarding',
+ options: ['A', 'B', 'C', 'D'],
+ correctIndex: 2,
+ explanation: 'C describes the buddy system best.',
+ },
+ ],
+ });
+ expect(parsed.questions[0].options).toHaveLength(4);
+ });
+
+ it('rejects three options or an out-of-range correctIndex', () => {
+ expect(() =>
+ quizQuestionsSchema.parse({
+ questions: [
+ {
+ id: 'q',
+ question: 'q',
+ topicLabel: 't',
+ options: ['A', 'B', 'C'],
+ correctIndex: 0,
+ explanation: 'e',
+ },
+ ],
+ }),
+ ).toThrow();
+ expect(() =>
+ quizQuestionsSchema.parse({
+ questions: [
+ {
+ id: 'q',
+ question: 'q',
+ topicLabel: 't',
+ options: ['A', 'B', 'C', 'D'],
+ correctIndex: 4,
+ explanation: 'e',
+ },
+ ],
+ }),
+ ).toThrow();
+ });
+});
+
+describe('customTopicSchema', () => {
+ it('accepts a polished custom topic', () => {
+ expect(() =>
+ customTopicSchema.parse({
+ label: 'Pair Programming',
+ type: 'process',
+ description: 'Two engineers, one keyboard.',
+ }),
+ ).not.toThrow();
+ });
+});
+
+describe('graphActionsSchema', () => {
+ it('fills missing arrays with empty defaults', () => {
+ const parsed = graphActionsSchema.parse({});
+ expect(parsed.merges).toEqual([]);
+ expect(parsed.deletions).toEqual([]);
+ expect(parsed.newRelations).toEqual([]);
+ expect(parsed.relevanceUpdates).toEqual([]);
+ });
+});
+
+describe('proposeGraphDeltaSchema', () => {
+ it('accepts a reason-only delta', () => {
+ expect(() => proposeGraphDeltaSchema.parse({ reason: 'Nothing to add.' })).not.toThrow();
+ });
+
+ it('caps topics at three and relations at five', () => {
+ const bigTopics = Array.from({ length: 4 }, (_, i) => ({
+ id: `t-${i}`,
+ label: `Topic ${i}`,
+ type: 'concept',
+ description: 'desc',
+ }));
+ expect(() =>
+ proposeGraphDeltaSchema.parse({ reason: 'too many', topics: bigTopics }),
+ ).toThrow();
+ });
+});
diff --git a/src/lib/api.js b/src/lib/api.js
index daf22e2..67a5354 100644
--- a/src/lib/api.js
+++ b/src/lib/api.js
@@ -1,135 +1,39 @@
-import { storage } from './storage';
-
/**
- * Anthropic API Service
- * Handles communication with the /v1/messages endpoint via Nginx proxy.
+ * Back-compatibility shim for the legacy `anthropicApi` interface.
+ *
+ * All real work lives in `./llm.js`. Existing callers (extractionPipeline,
+ * learningService, testService, KnowledgeGraph, useChat) keep working
+ * unchanged; new code should import `callLLM` from `./llm.js` directly.
*/
-const DEFAULT_MODEL = 'claude-sonnet-4-20250514';
+import { callLLM } from './llm';
export const anthropicApi = {
- async generateContent(systemPrompt, userMessage, maxRetries = 1) {
- // Check if simulation mode is on
- const useSimulation = storage.get('admin:use_simulation') === true;
- if (useSimulation) {
- console.log('[API] Simulation mode active. Mock data will be returned.');
- return await simulateResponse();
- }
+ async generateContent(systemPrompt, userMessage /*, maxRetries */) {
+ const { text } = await callLLM({
+ task: 'legacy.generateContent',
+ tier: 'standard',
+ system: systemPrompt,
+ user: userMessage,
+ maxTokens: 8192,
+ temperature: 0,
+ });
+ return text;
+ },
- // The API key is now securely injected by the Caddy reverse proxy via environment variables.
-
- // Model is configurable from Admin > Settings, defaults to the original spec model
- const model = storage.get('admin:model') || DEFAULT_MODEL;
- console.log(`[API] Calling with model: ${model}`);
-
- let retries = 0;
- while (retries <= maxRetries) {
- try {
- const response = await fetch('/api/anthropic/v1/messages', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'anthropic-version': '2023-06-01',
- },
- body: JSON.stringify({
- model: model,
- max_tokens: 8192,
- temperature: 0,
- system: systemPrompt,
- messages: [{ role: 'user', content: userMessage }]
- })
- });
-
- if (!response.ok) {
- const errData = await response.json().catch(() => ({}));
- console.error('[API] Error response:', errData);
- throw new Error(`API Error: ${response.status} ${response.statusText} - ${JSON.stringify(errData)}`);
- }
-
- // Detect auth portal session expiry: the portal returns HTML instead of JSON
- const contentType = response.headers.get('content-type') || '';
- if (!contentType.includes('application/json')) {
- throw new Error('Your session has expired. Please refresh the page and log in again.');
- }
-
- const data = await response.json();
- return data.content[0].text;
- } catch (error) {
- console.error('API call failed:', error);
- retries++;
- if (retries > maxRetries) throw error;
- await new Promise(r => setTimeout(r, 1000));
- }
- }
- }
+ async chat(systemPrompt, messages, opts = {}) {
+ const r = await callLLM({
+ task: 'legacy.chat',
+ tier: 'standard',
+ system: systemPrompt,
+ messages,
+ tools: opts.tools,
+ maxTokens: 1024,
+ temperature: 0.3,
+ });
+ const content = [];
+ if (r.text) content.push({ type: 'text', text: r.text });
+ for (const tu of r.toolUses) content.push({ type: 'tool_use', name: tu.name, input: tu.input });
+ return { content, stop_reason: r.stopReason };
+ },
};
-
-/**
- * Multi-turn chat with optional tool use.
- * Returns the raw Anthropic response so callers can read both `text` and
- * `tool_use` content blocks.
- *
- * @param {string} systemPrompt
- * @param {Array<{role: 'user'|'assistant', content: string}>} messages
- * @param {{tools?: Array}} opts
- * @returns {Promise<{content: Array, stop_reason: string}>}
- */
-anthropicApi.chat = async function chat(systemPrompt, messages, opts = {}) {
- const useSimulation = storage.get('admin:use_simulation') === true;
- if (useSimulation) {
- await new Promise(r => setTimeout(r, 600));
- return {
- content: [{
- type: 'text',
- text: 'Simulatiemodus staat aan β vraag een beheerder om Simulation Mode uit te zetten in Admin β Settings om met R42 te chatten.',
- }],
- stop_reason: 'end_turn',
- };
- }
-
- const model = storage.get('admin:model') || DEFAULT_MODEL;
-
- const body = {
- model,
- max_tokens: 1024,
- system: systemPrompt,
- messages,
- };
- if (opts.tools && opts.tools.length) body.tools = opts.tools;
-
- const response = await fetch('/api/anthropic/v1/messages', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'anthropic-version': '2023-06-01',
- },
- body: JSON.stringify(body),
- });
-
- if (!response.ok) {
- const errData = await response.json().catch(() => ({}));
- throw new Error(`API Error: ${response.status} ${response.statusText} - ${JSON.stringify(errData)}`);
- }
-
- const contentType = response.headers.get('content-type') || '';
- if (!contentType.includes('application/json')) {
- throw new Error('Your session has expired. Please refresh the page and log in again.');
- }
-
- return await response.json();
-};
-
-async function simulateResponse() {
- await new Promise(r => setTimeout(r, 2000));
- return JSON.stringify({
- topics: [
- { id: "radicale-transparantie", label: "Radicale Transparantie", type: "concept", description: "De kernwaarde van Respellion waarbij alle informatie publiek toegankelijk is." },
- { id: "kennisbeheer", label: "Kennisbeheer", type: "process", description: "Het proces van het vastleggen en ontsluiten van organisatiekennis." },
- { id: "wekelijkse-sessie", label: "Wekelijkse Leersessie", type: "process", description: "Elke week leren medewerkers via AI-gegenereerde vragen en quizzen." }
- ],
- relations: [
- { source: "kennisbeheer", target: "radicale-transparantie", type: "depends_on" },
- { source: "wekelijkse-sessie", target: "kennisbeheer", type: "part_of" }
- ]
- });
-}
diff --git a/src/lib/curriculumService.js b/src/lib/curriculumService.js
index 2d1fa76..9b99d28 100644
--- a/src/lib/curriculumService.js
+++ b/src/lib/curriculumService.js
@@ -184,10 +184,7 @@ export async function autoGenerateCurriculum(year) {
const weeks = [];
const reviewWeeks = [13, 26, 39, 52];
- // Calculate available weeks (52 total minus review weeks)
- const availableWeeks = 52 - reviewWeeks.length; // 48
-
- // Distribute topics across available weeks
+ // Distribute topics across the 48 non-review weeks.
let topicIndex = 0;
for (let w = 1; w <= 52; w++) {
diff --git a/src/lib/extractionPipeline.js b/src/lib/extractionPipeline.js
index 7a33a30..4de6d20 100644
--- a/src/lib/extractionPipeline.js
+++ b/src/lib/extractionPipeline.js
@@ -65,24 +65,20 @@ Return a JSON object:
Return JSON only. No markdown blocks or other text.`;
export async function analyzeHandbookDelta(fileContent, filePath) {
+ const responseText = await anthropicApi.generateContent(HANDBOOK_SYSTEM_PROMPT, `Analyze the following handbook file update (${filePath}):\n\n${fileContent}`);
+
+ let extractedData;
try {
- const responseText = await anthropicApi.generateContent(HANDBOOK_SYSTEM_PROMPT, `Analyze the following handbook file update (${filePath}):\n\n${fileContent}`);
-
- let extractedData;
- try {
- const jsonMatch = responseText.match(/\{[\s\S]*\}/);
- const jsonStr = jsonMatch ? jsonMatch[0] : responseText;
- extractedData = JSON.parse(jsonStr);
- } catch (e) {
- console.error('[Pipeline] AI returned non-JSON response for handbook delta:', responseText?.substring(0, 500));
- throw new Error(`AI response was not valid JSON. The model responded with: "${responseText?.substring(0, 120)}..."`);
- }
-
- await mergeKnowledgeGraph(extractedData);
- return { success: true, data: extractedData };
- } catch (error) {
- throw error;
+ const jsonMatch = responseText.match(/\{[\s\S]*\}/);
+ const jsonStr = jsonMatch ? jsonMatch[0] : responseText;
+ extractedData = JSON.parse(jsonStr);
+ } catch (e) {
+ console.error('[Pipeline] AI returned non-JSON response for handbook delta:', responseText?.substring(0, 500));
+ throw new Error(`AI response was not valid JSON. The model responded with: "${responseText?.substring(0, 120)}..."`, { cause: e });
}
+
+ await mergeKnowledgeGraph(extractedData);
+ return { success: true, data: extractedData };
}
function chunkText(text, maxChunkSize = 4000) {
const paragraphs = text.split(/\n+/);
@@ -141,7 +137,7 @@ export async function processSourceText(textContent, sourceName) {
extractedData = JSON.parse(jsonStr);
} catch (e) {
console.error(`[Pipeline] AI returned non-JSON response for chunk ${i + 1}:`, responseText?.substring(0, 500));
- throw new Error(`AI response for chunk ${i + 1} was not valid JSON.`);
+ throw new Error(`AI response for chunk ${i + 1} was not valid JSON.`, { cause: e });
}
if (extractedData.topics && Array.isArray(extractedData.topics)) {
diff --git a/src/lib/learningService.js b/src/lib/learningService.js
index 520a7a8..a8ca691 100644
--- a/src/lib/learningService.js
+++ b/src/lib/learningService.js
@@ -1,6 +1,6 @@
import { anthropicApi } from './api';
import * as db from './db';
-import { getCurriculumTopic, getCurriculumYear } from './curriculumService';
+import { getCurriculumTopic } from './curriculumService';
const CONTENT_GENERATION_SYSTEM = `You are an expert learning content writer for Respellion, an internal IT company.
You write training material for employees based on knowledge topics.
@@ -138,7 +138,7 @@ ${instructions}`;
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
newContent = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
} catch (e) {
- throw new Error('AI could not generate valid learning content. Please try again.');
+ throw new Error('AI could not generate valid learning content. Please try again.', { cause: e });
}
const mergedContent = { ...(cached || {}), ...newContent };
@@ -165,7 +165,7 @@ Apply the refinement and return the complete updated JSON object using the same
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
content = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
} catch (e) {
- throw new Error('AI could not process the refinement. Please try a different instruction.');
+ throw new Error('AI could not process the refinement. Please try a different instruction.', { cause: e });
}
await db.setContent(topic.id, content);
@@ -198,7 +198,7 @@ Return ONLY a JSON object with this structure:
newTopic = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
newTopic.id = 'custom_' + Date.now().toString(36);
} catch (e) {
- throw new Error('Could not process custom topic. Please try again.');
+ throw new Error('Could not process custom topic. Please try again.', { cause: e });
}
await db.upsertTopic(newTopic);
diff --git a/src/lib/llm.js b/src/lib/llm.js
new file mode 100644
index 0000000..a9b2edc
--- /dev/null
+++ b/src/lib/llm.js
@@ -0,0 +1,366 @@
+/**
+ * Single Anthropic client used by every service module.
+ *
+ * Centralises model selection, retry, timeout/abort, structured-output
+ * parsing, schema validation, and best-effort call telemetry. Callers
+ * import `callLLM` from here β they must not reach `/api/anthropic` on
+ * their own.
+ */
+
+import { storage } from './storage';
+import { withRetry, RetryableError, parseRetryAfter, isRetryableStatus } from './llmRetry';
+import { toolSchemaRegistry } from './llmSchemas';
+import { pb } from './pb';
+
+const ANTHROPIC_URL = '/api/anthropic/v1/messages';
+const ANTHROPIC_VERSION = '2023-06-01';
+const DEFAULT_TIMEOUT_MS = 60_000;
+
+const TIER_DEFAULTS = {
+ fast: 'claude-haiku-4-5-20251001',
+ standard: 'claude-sonnet-4-6',
+ reasoning: 'claude-opus-4-7',
+};
+
+export class LLMHttpError extends Error {
+ constructor(status, statusText, body) {
+ super(`API Error: ${status} ${statusText} - ${typeof body === 'string' ? body : JSON.stringify(body)}`);
+ this.name = 'LLMHttpError';
+ this.status = status;
+ this.body = body;
+ }
+}
+
+export class LLMTruncatedError extends Error {
+ constructor(task) {
+ super(`LLM response truncated (stop_reason: max_tokens) for task "${task}". Increase max_tokens or shorten the input.`);
+ this.name = 'LLMTruncatedError';
+ }
+}
+
+export class LLMOutputError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = 'LLMOutputError';
+ }
+}
+
+export class LLMValidationError extends Error {
+ constructor(task, zodError) {
+ super(`LLM output failed schema validation for task "${task}": ${zodError?.message ?? zodError}`);
+ this.name = 'LLMValidationError';
+ this.cause = zodError;
+ }
+}
+
+export function resolveModel(tier) {
+ const key = `admin:model:${tier}`;
+ const override = storage.get(key);
+ if (override) return String(override).trim();
+ if (tier === 'standard') {
+ const legacy = storage.get('admin:model');
+ if (legacy) return String(legacy).trim();
+ }
+ return TIER_DEFAULTS[tier] ?? TIER_DEFAULTS.standard;
+}
+
+/**
+ * Extract the outermost balanced JSON value (object or array) from arbitrary
+ * model output. Strips ```json fences first. Brace-matching ignores braces
+ * inside strings; escapes inside strings are skipped.
+ */
+export function parseStructuredText(raw) {
+ if (typeof raw !== 'string') throw new LLMOutputError('LLM returned no text.');
+ let text = raw.trim();
+ text = text.replace(/```(?:json)?\s*/gi, '').replace(/```/g, '');
+
+ for (let i = 0; i < text.length; i++) {
+ const ch = text[i];
+ if (ch !== '{' && ch !== '[') continue;
+ const open = ch;
+ const close = ch === '{' ? '}' : ']';
+ let depth = 0;
+ let inString = false;
+ for (let j = i; j < text.length; j++) {
+ const c = text[j];
+ if (inString) {
+ if (c === '\\') { j++; continue; }
+ if (c === '"') inString = false;
+ continue;
+ }
+ if (c === '"') { inString = true; continue; }
+ if (c === open) depth++;
+ else if (c === close) {
+ depth--;
+ if (depth === 0) {
+ const slice = text.slice(i, j + 1);
+ try {
+ return JSON.parse(slice);
+ } catch {
+ break;
+ }
+ }
+ }
+ }
+ }
+ throw new LLMOutputError('No balanced JSON value found in LLM output.');
+}
+
+function buildMessages({ messages, user }) {
+ if (Array.isArray(messages) && messages.length) return messages;
+ if (typeof user === 'string' && user.length) return [{ role: 'user', content: user }];
+ throw new Error('callLLM requires either `messages` or `user`.');
+}
+
+function logLlmCall(record) {
+ try {
+ pb.collection('llm_calls').create(record).catch(() => {});
+ } catch {
+ /* collection may not exist yet β swallow */
+ }
+}
+
+function isChatLikeTask(task) {
+ if (!task) return false;
+ return task === 'legacy.chat' || task.startsWith('chat.') || task.startsWith('r42.');
+}
+
+const SIMULATION_EXTRACTION_PAYLOAD = JSON.stringify({
+ topics: [
+ { id: 'radicale-transparantie', label: 'Radicale Transparantie', type: 'concept', description: 'De kernwaarde van Respellion waarbij alle informatie publiek toegankelijk is.', learning_relevance: 'core' },
+ { id: 'kennisbeheer', label: 'Kennisbeheer', type: 'process', description: 'Het proces van het vastleggen en ontsluiten van organisatiekennis.', learning_relevance: 'standard' },
+ { id: 'wekelijkse-sessie', label: 'Wekelijkse Leersessie', type: 'process', description: 'Elke week leren medewerkers via AI-gegenereerde vragen en quizzen.', learning_relevance: 'standard' },
+ ],
+ relations: [
+ { source: 'kennisbeheer', target: 'radicale-transparantie', type: 'depends_on' },
+ { source: 'wekelijkse-sessie', target: 'kennisbeheer', type: 'part_of' },
+ ],
+});
+
+const SIMULATION_CHAT_TEXT =
+ 'Simulatiemodus staat aan β vraag een beheerder om Simulation Mode uit te zetten in Admin β Settings om met R42 te chatten.';
+
+async function simulatedResponse({ task }) {
+ await new Promise((r) => setTimeout(r, 400));
+ if (isChatLikeTask(task)) {
+ return {
+ text: SIMULATION_CHAT_TEXT,
+ toolUses: [],
+ stopReason: 'end_turn',
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
+ requestId: null,
+ model: 'simulation',
+ durationMs: 400,
+ };
+ }
+ return {
+ text: SIMULATION_EXTRACTION_PAYLOAD,
+ toolUses: [],
+ stopReason: 'end_turn',
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
+ requestId: null,
+ model: 'simulation',
+ durationMs: 400,
+ };
+}
+
+function linkSignals(userSignal, timeoutSignal) {
+ const controller = new AbortController();
+ const abort = (reason) => controller.abort(reason);
+ if (userSignal) {
+ if (userSignal.aborted) controller.abort(userSignal.reason);
+ else userSignal.addEventListener('abort', () => abort(userSignal.reason), { once: true });
+ }
+ if (timeoutSignal) {
+ if (timeoutSignal.aborted) controller.abort(timeoutSignal.reason);
+ else timeoutSignal.addEventListener('abort', () => abort(timeoutSignal.reason), { once: true });
+ }
+ return controller.signal;
+}
+
+function extractToolUses(content) {
+ if (!Array.isArray(content)) return [];
+ return content
+ .filter((b) => b?.type === 'tool_use')
+ .map((b) => ({ name: b.name, input: b.input }));
+}
+
+function extractText(content) {
+ if (!Array.isArray(content)) return '';
+ return content
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
+ .map((b) => b.text)
+ .join('');
+}
+
+function validateToolInputs(toolUses, task, toolSchemas) {
+ const registry = { ...toolSchemaRegistry, ...(toolSchemas || {}) };
+ for (const tu of toolUses) {
+ const schema = registry[tu.name];
+ if (!schema) continue;
+ const result = schema.safeParse(tu.input);
+ if (!result.success) throw new LLMValidationError(`${task}:${tu.name}`, result.error);
+ tu.input = result.data;
+ }
+}
+
+/**
+ * @typedef {Object} CallLLMOptions
+ * @property {string} task Logging label, e.g. 'extract.source'.
+ * @property {'fast'|'standard'|'reasoning'} [tier='standard']
+ * @property {string|Array<{type:'text',text:string,cache_control?:{type:'ephemeral'}}>} [system]
+ * @property {Array<{role:'user'|'assistant',content:any}>} [messages]
+ * @property {string} [user] Shorthand for a single user message.
+ * @property {Array