Compare commits
241 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dacd0a50c | |||
| 3c73da4427 | |||
| cff2e55db7 | |||
| 429a25faa9 | |||
| 486a2eeca3 | |||
| 26a6147e8d | |||
| a7b53988ff | |||
| 3f3e2f0bcf | |||
| f956ab07f3 | |||
| 736594696e | |||
| 1be997f7d8 | |||
| 5a8ff16afa | |||
| 4435dc32b8 | |||
| f5a33e0c1e | |||
| a8326b6f5a | |||
| 1e999d968a | |||
| d0f74c9f02 | |||
| f14f1e41b9 | |||
| 9138e722bb | |||
| 9695e269e3 | |||
| 82bdf62c7b | |||
| 8893e3afe0 | |||
| cd1e7d8fa8 | |||
| 059e72df69 | |||
| dc519b3ea7 | |||
| ad4074f107 | |||
| 0534d7b3a7 | |||
| a0554c2346 | |||
| bc5af23896 | |||
| 92be8a7ca5 | |||
| 44ea1ea0ba | |||
| 3bee600096 | |||
| 5b4030f86b | |||
| e2c00ba18b | |||
| 1050029842 | |||
| 64edf899ac | |||
| 0a9075af73 | |||
| c3a5383f5e | |||
| e96813edcf | |||
| 5e36e38b72 | |||
| 890473ebd5 | |||
| 2b86d1fda7 | |||
| 1487ad97f1 | |||
| fc27b254bb | |||
| 658536c217 | |||
| 00018959c6 | |||
| 117af4a8a7 | |||
| 84e18e4954 | |||
| 090e754077 | |||
| 4322159d8e | |||
| bdf5767a5f | |||
| e8b071aecd | |||
| f128346a54 | |||
| 5bf6af7afc | |||
| 1a941a6f1d | |||
| 508e17bc11 | |||
| 4d00858b8a | |||
| 7e5b23808d | |||
| bfcc205f05 | |||
| a6a9f3bbdd | |||
| 5651c11803 | |||
| ff749611c6 | |||
| 49f7a447d3 | |||
| ea5447056b | |||
| baec23cf73 | |||
| a202f2dc95 | |||
| a2951f2c99 | |||
| 6395ef655f | |||
| cbf9325e7e | |||
| a32465ad32 | |||
| 01abb90797 | |||
| bce74d6c56 | |||
| 75d0593198 | |||
| 82b8eb1067 | |||
| ce9c94a77b | |||
| 7a7876b9f4 | |||
| 9b8626622f | |||
| dad2a3d523 | |||
| b3180b898e | |||
| 81047917a1 | |||
| 31dba65906 | |||
| 770fb2a2b3 | |||
| 8c7fc989dc | |||
| 58204e72bd | |||
| 53cefba3f3 | |||
| 46613ada23 | |||
| 5d334d7ec8 | |||
| cd60d4d859 | |||
| 454fc1a5f4 | |||
| 767d91146a | |||
| c6b3f609b7 | |||
| 7b50b12fe6 | |||
| d8f7143b4c | |||
| d206435af1 | |||
| d8213bfa42 | |||
| 8c0707f34c | |||
| 8c6f5deab2 | |||
| 23c446b7e9 | |||
| 8ec560884e | |||
| 269fce0331 | |||
| 72661196ee | |||
| fabefed39f | |||
| 507d4f73bc | |||
| 6e43490ba0 | |||
| 79fb71bc92 | |||
| beb2c0c258 | |||
| bebfeeff97 | |||
| 35566690e9 | |||
| 357bcb72e3 | |||
| 47e04b506f | |||
| 9373667084 | |||
| 87dc70a4c0 | |||
| 5b6de2bb16 | |||
| 338548bf7f | |||
| a94464a953 | |||
| 88e6f332d3 | |||
| d07b8ba56e | |||
| e9f7d5e77b | |||
| 9a7c520106 | |||
| 5ccd4674ac | |||
| d1b8cb20a3 | |||
| dd2431db97 | |||
| 746f53ff7e | |||
| 7f9a4da52a | |||
| 8043b7952d | |||
| e198392060 | |||
| deea669e28 | |||
| d6b624e400 | |||
| 263d650460 | |||
| a3fc696510 | |||
| 9f6f62c6ae | |||
| a70ff750a6 | |||
| 815a523d1c | |||
| 30382dc366 | |||
| 3292a092ae | |||
| 8afc20e1ab | |||
| 4365417807 | |||
| 51c59e28b8 | |||
| d6b15efa63 | |||
| 199a6f03a7 | |||
| d3f797c803 | |||
| bb4412fae5 | |||
| 08d7c37217 | |||
| fa29ee9ea4 | |||
| 317c1ee5ad | |||
| 49dd591f8f | |||
| 6d841f98e0 | |||
| 7e28b8ce6d | |||
| de15c32119 | |||
| b651144537 | |||
| 6b4d71ab03 | |||
| a11c8bfaa7 | |||
| 0095f2105a | |||
| 9e40092507 | |||
| 72c61f242e | |||
| cee82bd147 | |||
| 2becbd17bc | |||
| 7d736e1a34 | |||
| 3eded5f5be | |||
| cf8cf9058d | |||
| 7b15135ebf | |||
| 6c4d743259 | |||
| 3b9edc3a90 | |||
| 51bfebdfb6 | |||
| 6c43304205 | |||
| ab02794ff2 | |||
| f5197159f7 | |||
| 3516c0b6b2 | |||
| bda07d6a3e | |||
| 09ffc7d946 | |||
| 8b93898b73 | |||
| 499458f8b2 | |||
| 51ed038374 | |||
| 2c98ae8e4a | |||
| 4570ace78c | |||
| cdb473d427 | |||
| 4a782c6d07 | |||
| c8ce36e9af | |||
| e291735c95 | |||
| de4f72020e | |||
| 84ce492e2a | |||
| 9383ac63a8 | |||
| 9b08da8e0b | |||
| 6099615337 | |||
| f669c1f322 | |||
| d8bd6ab9e2 | |||
| fd01a090fc | |||
| 0f5ecb70a2 | |||
| 984ee67517 | |||
| a8ff98b660 | |||
| c31b2162ad | |||
| a440aefd43 | |||
| 6e92c2777c | |||
| b4e188fbfc | |||
| b52990bf27 | |||
| c94545419b | |||
| 59313dfaff | |||
| 8547006742 | |||
| 809002adb3 | |||
| 18daedecda | |||
| 92f059f313 | |||
| 8516441cab | |||
| 9231eb4db6 | |||
| 18b8e4af61 | |||
| 8365d77c1d | |||
| 42ce65708f | |||
| a79c3778e7 | |||
| 8fe2ae80af | |||
| 588ec8feb1 | |||
| dbb70fc525 | |||
| 2563afa582 | |||
| 3967ca2790 | |||
| d2a2728cde | |||
| acd3b3aafc | |||
| 0427ccc23b | |||
| 56f26b5efd | |||
| 6d6efddc4e | |||
| 3c0f324266 | |||
| f60f84dc6d | |||
| cf94b45b65 | |||
| f0a3fe9dbb | |||
| 70c7d1ba3f | |||
| 7c57b1e818 | |||
| a372313ea1 | |||
| 422e213438 | |||
| d1ee2c05f4 | |||
| 7bb5784913 | |||
| ccaa0247a5 | |||
| c37d3f773e | |||
| 4406d5085f | |||
| 0fb5d0a8c2 | |||
| e9b3e135eb | |||
| 13f7b00b35 | |||
| 188c6153d5 | |||
| efa3ea4879 | |||
| ef77b312fa | |||
| 4641788fc9 | |||
| d24db65cb8 | |||
| 123e27643d | |||
| b64da26651 | |||
| d80577480c |
@@ -53,3 +53,54 @@ own switch in the admin panel. https://cloud.google.com/maps-platform/terms
|
||||
|
||||
`server/assets/airports.json` is built from **OurAirports**
|
||||
(https://ourairports.com/data/), released into the public domain.
|
||||
|
||||
## Typefaces: Studio books and the app
|
||||
|
||||
Every typeface a book can be set in, and the app's own (Poppins, MuseoModerno,
|
||||
Geist), is self-hosted from the **@fontsource** npm packages, which redistribute
|
||||
the families published on Google Fonts split into subsets by script. The catalogue of book faces is
|
||||
`shared/src/book/book-fonts.data.ts`. Each package carries its licence and the
|
||||
copyright notice of the family's authors in its `LICENSE` file.
|
||||
|
||||
Both licences allow a font to be bundled with software and embedded in a
|
||||
document made with it, so a printed or exported book may carry the faces it is
|
||||
set in. No family is renamed, redrawn or sold on its own. SIL OFL 1.1: https://openfontlicense.org. Apache 2.0:
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
| Family | Package | Licence |
|
||||
|---|---|---|
|
||||
| Abril Fatface | `@fontsource/abril-fatface` | SIL Open Font License 1.1 |
|
||||
| Alfa Slab One | `@fontsource/alfa-slab-one` | SIL Open Font License 1.1 |
|
||||
| Allura | `@fontsource/allura` | SIL Open Font License 1.1 |
|
||||
| Anton | `@fontsource/anton` | SIL Open Font License 1.1 |
|
||||
| Archivo Black | `@fontsource/archivo-black` | SIL Open Font License 1.1 |
|
||||
| Bebas Neue | `@fontsource/bebas-neue` | SIL Open Font License 1.1 |
|
||||
| Big Shoulders Display | `@fontsource/big-shoulders-display` | SIL Open Font License 1.1 |
|
||||
| Bodoni Moda | `@fontsource/bodoni-moda` | SIL Open Font License 1.1 |
|
||||
| Bricolage Grotesque | `@fontsource/bricolage-grotesque` | SIL Open Font License 1.1 |
|
||||
| Caveat | `@fontsource/caveat` | SIL Open Font License 1.1 |
|
||||
| Cormorant Garamond | `@fontsource/cormorant-garamond` | SIL Open Font License 1.1 |
|
||||
| Dancing Script | `@fontsource/dancing-script` | SIL Open Font License 1.1 |
|
||||
| DM Mono | `@fontsource/dm-mono` | SIL Open Font License 1.1 |
|
||||
| DM Serif Display | `@fontsource/dm-serif-display` | SIL Open Font License 1.1 |
|
||||
| EB Garamond | `@fontsource/eb-garamond` | SIL Open Font License 1.1 |
|
||||
| Fraunces | `@fontsource/fraunces` | SIL Open Font License 1.1 |
|
||||
| Great Vibes | `@fontsource/great-vibes` | SIL Open Font License 1.1 |
|
||||
| Homemade Apple | `@fontsource/homemade-apple` | Apache License 2.0 |
|
||||
| Inter | `@fontsource/inter` | SIL Open Font License 1.1 |
|
||||
| Kaushan Script | `@fontsource/kaushan-script` | SIL Open Font License 1.1 |
|
||||
| League Gothic | `@fontsource/league-gothic` | SIL Open Font License 1.1 |
|
||||
| Lora | `@fontsource/lora` | SIL Open Font License 1.1 |
|
||||
| Montserrat | `@fontsource/montserrat` | SIL Open Font License 1.1 |
|
||||
| MuseoModerno | `@fontsource/museomoderno` | SIL Open Font License 1.1 |
|
||||
| Oswald | `@fontsource/oswald` | SIL Open Font License 1.1 |
|
||||
| Pacifico | `@fontsource/pacifico` | SIL Open Font License 1.1 |
|
||||
| Parisienne | `@fontsource/parisienne` | SIL Open Font License 1.1 |
|
||||
| Permanent Marker | `@fontsource/permanent-marker` | Apache License 2.0 |
|
||||
| Playfair Display | `@fontsource/playfair-display` | SIL Open Font License 1.1 |
|
||||
| Poppins | `@fontsource/poppins` | SIL Open Font License 1.1 |
|
||||
| Sacramento | `@fontsource/sacramento` | SIL Open Font License 1.1 |
|
||||
| Satisfy | `@fontsource/satisfy` | Apache License 2.0 |
|
||||
| Shrikhand | `@fontsource/shrikhand` | SIL Open Font License 1.1 |
|
||||
| Yellowtail | `@fontsource/yellowtail` | Apache License 2.0 |
|
||||
| Geist Sans | `@fontsource/geist-sans` | SIL Open Font License 1.1 |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 4.3.3
|
||||
version: 4.3.4
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "4.3.3"
|
||||
appVersion: "4.3.4"
|
||||
|
||||
@@ -112,6 +112,9 @@ data:
|
||||
{{- if .Values.env.TREK_PLACES_URL }}
|
||||
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.GOOGLE_LIST_DATA_BASE }}
|
||||
GOOGLE_LIST_DATA_BASE: {{ .Values.env.GOOGLE_LIST_DATA_BASE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.AMAP_API_BASE }}
|
||||
AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -108,6 +108,7 @@ env:
|
||||
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
|
||||
# here. Defaults to on.
|
||||
# TREK_PLACES_URL: ""
|
||||
# GOOGLE_LIST_DATA_BASE: ""
|
||||
# Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from
|
||||
# configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
|
||||
# Defaults to https://places.liketrek.com.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { BOOK_FONT_FACES, BOOK_FONTS_PRECACHED } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Which font files the service worker installs with the app.
|
||||
*
|
||||
* The precache takes every woff and woff2 the build emits, and with the book's
|
||||
* catalogue at thirty four families that is every weight, both styles and
|
||||
* every script subset of each: megabytes fetched at install by every user,
|
||||
* most of it for faces no book of theirs is set in. So the book's faces are
|
||||
* held to this:
|
||||
*
|
||||
* - The faces the app itself draws (Poppins and MuseoModerno for the chrome,
|
||||
* imported in src/main.tsx) stay exactly as they were.
|
||||
* - The faces the book shipped with before the catalogue grew
|
||||
* (`BOOK_FONTS_PRECACHED`) keep their latin woff2, so a book set in them
|
||||
* still opens offline before Studio was ever opened online.
|
||||
* - Every other file of a book family, woff, other subsets, new families,
|
||||
* real italics, is left to the `book-fonts` runtime cache in vite.config.js,
|
||||
* which the Studio fills with the faces of each book it opens
|
||||
* (src/components/Studio/bookFontWarm.ts).
|
||||
*
|
||||
* Shared by vite.config.js and its test, which reads the filter against file
|
||||
* names as the build writes them.
|
||||
*/
|
||||
|
||||
/** The faces src/main.tsx imports for the app chrome, which the book shares; bookFontPrecache.test.ts checks this copy. */
|
||||
export const APP_FACES = {
|
||||
poppins: [300, 400, 500, 600, 700],
|
||||
museomoderno: [400, 700, 800],
|
||||
};
|
||||
|
||||
/** The book packages, longest name first, so `dm-serif-display` is never read as `dm-…`. */
|
||||
const BOOK_PACKAGES = [...new Set(Object.values(BOOK_FONT_FACES).map((f) => f.pkg))].sort(
|
||||
(a, b) => b.length - a.length
|
||||
);
|
||||
|
||||
const PRECACHED = new Map(
|
||||
Object.entries(BOOK_FONTS_PRECACHED).map(([id, weights]) => [BOOK_FONT_FACES[id].pkg, new Set(weights)])
|
||||
);
|
||||
|
||||
/**
|
||||
* A font file as the build names it, `<package>-<subset>-<weight>-<style>-<hash>.<ext>`,
|
||||
* read against the book's packages; null for any other file.
|
||||
*/
|
||||
export function bookFontFile(url) {
|
||||
const name = url.split('/').pop() ?? '';
|
||||
const match = /^(.+)-(\d{3})-(normal|italic)-[\w-]+\.(woff2?)$/.exec(name);
|
||||
if (!match) return null;
|
||||
const [, head, weight, style, ext] = match;
|
||||
const pkg = BOOK_PACKAGES.find((p) => head.startsWith(`${p}-`));
|
||||
if (!pkg) return null;
|
||||
return { pkg, subset: head.slice(pkg.length + 1), weight: Number(weight), italic: style === 'italic', ext };
|
||||
}
|
||||
|
||||
/** Whether a file of the build goes into the install-time precache. */
|
||||
export function keepInPrecache(url) {
|
||||
const file = bookFontFile(url);
|
||||
if (!file) return true;
|
||||
if (!file.italic && APP_FACES[file.pkg]?.includes(file.weight)) return true;
|
||||
const kept = PRECACHED.get(file.pkg);
|
||||
return !file.italic && file.subset === 'latin' && file.ext === 'woff2' && !!kept?.has(file.weight);
|
||||
}
|
||||
|
||||
/** The precache manifest without the book faces left to the runtime cache: a Workbox `manifestTransforms` entry. */
|
||||
export function withoutRuntimeFonts(entries) {
|
||||
return { manifest: entries.filter((entry) => keepInPrecache(entry.url)), warnings: [] };
|
||||
}
|
||||
@@ -451,7 +451,7 @@ const releaseRecordedDay = (page: Page) => releaseJournalDawarich(page.request,
|
||||
// ── Studio ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const rail = (page: Page) => page.locator('.st-rail')
|
||||
const railButton = (page: Page, name: string) => rail(page).getByRole('button', { name, exact: true })
|
||||
const railButton = (page: Page, name: string) => rail(page).getByRole('tab', { name, exact: true })
|
||||
const sidePanel = (page: Page) => page.locator('.st-panel.st-side')
|
||||
const inspector = (page: Page) => page.locator('.st-inspector')
|
||||
const studioMenu = (page: Page) => page.locator('.st-menu').last()
|
||||
@@ -460,6 +460,20 @@ const exportDialog = (page: Page) => page.getByRole('dialog', { name: 'Export' }
|
||||
const thumbs = (page: Page) => page.locator('.st-thumb-row')
|
||||
/** The hit targets over the spread, one per element. */
|
||||
const sheetElements = (page: Page) => page.locator('.st-sheet > div[style*="left:"]')
|
||||
/**
|
||||
* The hit target of the first text on the spread on screen. The targets above the page are empty
|
||||
* boxes, so the one over the drawn words is found by where the words are and marked for the locator.
|
||||
*/
|
||||
async function markTextTarget(page: Page): Promise<void> {
|
||||
const words = page.locator('.st-sheet').getByText(/\p{L}{3,}/u).first()
|
||||
await expect(words).toBeVisible()
|
||||
await words.evaluate(el => {
|
||||
const r = el.getBoundingClientRect()
|
||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
|
||||
hit?.setAttribute('data-help-text', '')
|
||||
})
|
||||
}
|
||||
const textTarget = (page: Page) => page.locator('.st-sheet [data-help-text]')
|
||||
|
||||
async function studioSection(page: Page, name: string): Promise<void> {
|
||||
await railButton(page, name).click()
|
||||
@@ -1300,7 +1314,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.locator('.st-back'),
|
||||
target: p => p.locator('.st-head-close'),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1431,7 +1445,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await sidePanel(p).locator('.st-tabs button').filter({ hasText: 'Entries' }).click()
|
||||
await sidePanel(p).getByRole('tab', { name: /Entries/ }).click()
|
||||
await expect(sidePanel(p).locator('.st-entry').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
@@ -1458,16 +1472,162 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
],
|
||||
},
|
||||
|
||||
'studio-my-layouts': {
|
||||
guide: guide('studio-my-layouts'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => railButton(p, 'Layouts'),
|
||||
act: async p => { await studioSection(p, 'Layouts') },
|
||||
},
|
||||
{
|
||||
// Pointed at rather than pressed, so the seed book keeps no layout of its own for the guides after this one.
|
||||
prepare: async p => {
|
||||
await sidePanel(p).getByRole('button', { name: 'Save this page as a layout' }).scrollIntoViewIfNeeded()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => sidePanel(p).locator('.st-saved-layouts'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-theme': {
|
||||
guide: guide('studio-theme'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
target: p => railButton(p, 'Theme'),
|
||||
act: async p => { await studioSection(p, 'Theme') },
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-theme-grid'),
|
||||
act: async p => {
|
||||
await sidePanel(p).locator('.st-theme-card').nth(1).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await sidePanel(p).getByRole('button', { name: 'Apply to the book' }).scrollIntoViewIfNeeded()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => sidePanel(p).locator('.st-theme-apply'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-stickers': {
|
||||
guide: guide('studio-stickers'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => { await studioSection(p, 'Elements') },
|
||||
target: p => sidePanel(p).getByRole('tab', { name: 'Stickers' }),
|
||||
act: async p => {
|
||||
await sidePanel(p).getByRole('tab', { name: 'Stickers' }).click()
|
||||
await expect(sidePanel(p).locator('.st-shape-grid').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-shape-grid').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-travel': {
|
||||
guide: guide('studio-travel'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
target: p => railButton(p, 'Travel'),
|
||||
act: async p => { await studioSection(p, 'Travel') },
|
||||
act: async p => {
|
||||
await studioSection(p, 'Travel')
|
||||
await expect(sidePanel(p).locator('.st-travel-card').first()).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: sidePanel,
|
||||
target: p => sidePanel(p).locator('.st-travel-grid').first(),
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-travel-card').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-text-effects': {
|
||||
guide: guide('studio-text-effects'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
await markTextTarget(p)
|
||||
},
|
||||
target: p => textTarget(p),
|
||||
act: async p => {
|
||||
await textTarget(p).click()
|
||||
await expect(inspector(p).getByRole('button', { name: 'Effects' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
const head = inspector(p).getByRole('button', { name: 'Effects' })
|
||||
if ((await head.getAttribute('aria-expanded')) !== 'true') await head.click()
|
||||
await head.scrollIntoViewIfNeeded()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => inspector(p).locator('.st-section').filter({ has: p.getByRole('button', { name: 'Effects' }) }),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-background': {
|
||||
guide: guide('studio-background'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => inspector(p).locator('.st-section').filter({ hasText: 'Page background' }).first(),
|
||||
},
|
||||
{
|
||||
target: p => inspector(p).locator('.st-paint-tabs').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-layers': {
|
||||
guide: guide('studio-layers'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => railButton(p, 'Layers'),
|
||||
act: async p => {
|
||||
await studioSection(p, 'Layers')
|
||||
await expect(sidePanel(p).locator('.st-layer').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-layer').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { devices, test } from '@playwright/test';
|
||||
|
||||
import { checkPageSnapshot } from './support/pageSnapshotFlow';
|
||||
|
||||
// The Studio's page image for the assistant, drawn in WebKit. playwright.config.ts
|
||||
// has no WebKit project; the engine comes from this file-level device, as in
|
||||
// e2e/urlbar-scroll-1809.spec.ts. Safari is why the rasteriser is
|
||||
// modern-screenshot: it decodes images in a foreignObject late, and a page
|
||||
// drawn too early comes out blank. page.route does not see a request the
|
||||
// service worker answers, so it is blocked.
|
||||
test.use({ ...devices['Desktop Safari'], serviceWorkers: 'block' });
|
||||
|
||||
test('Studio page snapshot: WebKit draws a seeded spread to a JPEG', async ({ page }) => {
|
||||
await checkPageSnapshot(page);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
import { checkPageSnapshot } from './support/pageSnapshotFlow';
|
||||
|
||||
// The Studio's page image for the assistant, drawn in Chromium. The same flow
|
||||
// runs in WebKit from page-snapshot-webkit.spec.ts, the engine with the most
|
||||
// to get wrong about drawing a page through an SVG foreignObject.
|
||||
// page.route does not see a request the service worker answers, so it is blocked.
|
||||
test.use({ serviceWorkers: 'block' });
|
||||
|
||||
test('Studio page snapshot: Chromium draws a seeded spread to a JPEG', async ({ page }) => {
|
||||
await checkPageSnapshot(page);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { APIRequestContext, Browser, Page } from '@playwright/test';
|
||||
import { devices, expect, test } from '@playwright/test';
|
||||
import { E2E_STATE_FILE } from '../playwright.config';
|
||||
|
||||
// A stay booked somewhere else shows in the open planner without a reload. The
|
||||
// assistant books through its tools, and a stay it booked showed in the planner
|
||||
// only after a reload: the server told every tab, the client dropped the news.
|
||||
// Here a second session (a browser context of its own, so the open planner's
|
||||
// socket is not the one that asked) books the stay over the API, and the planner
|
||||
// has to show it on its own, on the desktop and on the phone.
|
||||
|
||||
/** A trip with three dated days, and their ids in order. */
|
||||
async function seedTrip(api: APIRequestContext, title: string) {
|
||||
const created = await api.post('/api/trips', {
|
||||
data: { title, start_date: '2099-07-01', end_date: '2099-07-03' },
|
||||
});
|
||||
expect(created.ok(), 'seed trip').toBeTruthy();
|
||||
const body = await created.json();
|
||||
const tripId: number = body.trip?.id ?? body.id;
|
||||
const days = await (await api.get(`/api/trips/${tripId}/days`)).json();
|
||||
const dayIds: number[] = (days.days ?? days).map((day: { id: number }) => day.id);
|
||||
expect(dayIds.length, 'the trip has its three days').toBe(3);
|
||||
return { tripId, dayIds };
|
||||
}
|
||||
|
||||
/** Opens the planner and waits until its socket joined the trip's room, so no event can slip past it. */
|
||||
async function openPlanner(page: Page, tripId: number) {
|
||||
const joined = new Promise<void>((resolve) => {
|
||||
page.on('websocket', (ws) => {
|
||||
ws.on('framereceived', (frame) => {
|
||||
if (String(frame.payload).includes('"joined"')) resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
await page.goto(`/trips/${tripId}`);
|
||||
await joined;
|
||||
}
|
||||
|
||||
/** Books a hotel from the first to the second day, from a session of its own. */
|
||||
async function bookElsewhere(browser: Browser, tripId: number, dayIds: number[], hotel: string) {
|
||||
const other = await browser.newContext({ storageState: E2E_STATE_FILE });
|
||||
try {
|
||||
const place = await other.request.post(`/api/trips/${tripId}/places`, {
|
||||
data: { name: hotel, lat: 52.5306, lng: 13.3846 },
|
||||
});
|
||||
expect(place.ok(), 'the hotel place').toBeTruthy();
|
||||
const placeBody = await place.json();
|
||||
const stay = await other.request.post(`/api/trips/${tripId}/accommodations`, {
|
||||
data: { place_id: placeBody.place?.id ?? placeBody.id, start_day_id: dayIds[0], end_day_id: dayIds[1] },
|
||||
});
|
||||
expect(stay.ok(), 'the stay').toBeTruthy();
|
||||
} finally {
|
||||
await other.close();
|
||||
}
|
||||
}
|
||||
|
||||
test('desktop: a stay booked in another session shows in the day plan without a reload', async ({ page, browser }) => {
|
||||
const hotel = `Mercure Hotel ${Date.now()}`;
|
||||
const { tripId, dayIds } = await seedTrip(page.request, `Stay live ${Date.now()}`);
|
||||
await openPlanner(page, tripId);
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await bookElsewhere(browser, tripId, dayIds, hotel);
|
||||
|
||||
// The day head names the hotel of the night, read from the planner's stays.
|
||||
await expect(page.locator('[data-dp="day-pill"]', { hasText: hotel }).first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('phone: a stay booked in another session shows on the day plan without a reload', async ({ browser }) => {
|
||||
const pixel = devices['Pixel 7'];
|
||||
const phone = await browser.newContext({
|
||||
viewport: pixel.viewport,
|
||||
userAgent: pixel.userAgent,
|
||||
deviceScaleFactor: pixel.deviceScaleFactor,
|
||||
isMobile: pixel.isMobile,
|
||||
hasTouch: pixel.hasTouch,
|
||||
storageState: E2E_STATE_FILE,
|
||||
});
|
||||
try {
|
||||
const page = await phone.newPage();
|
||||
const hotel = `Mercure Hotel ${Date.now()}`;
|
||||
const { tripId, dayIds } = await seedTrip(page.request, `Stay live phone ${Date.now()}`);
|
||||
await openPlanner(page, tripId);
|
||||
await expect(page.locator('button[aria-current="true"]')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await bookElsewhere(browser, tripId, dayIds, hotel);
|
||||
|
||||
// The first day's plan carries a chip for the hotel checked into that night.
|
||||
await expect(page.locator('button', { hasText: hotel }).first()).toBeVisible({ timeout: 10_000 });
|
||||
} finally {
|
||||
await phone.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
import type { SeededSnapshot } from './pageSnapshotHarness';
|
||||
|
||||
/** The map tile from another server that the harness puts on the sheet. */
|
||||
const TILE = 'https://tile.openstreetmap.org/5/16/10.png?e2e=page-snapshot';
|
||||
|
||||
/**
|
||||
* The page snapshot the Studio sends the assistant, taken in a real browser
|
||||
* (journey-studio.md 3.1): the harness module is loaded into the running app
|
||||
* through the dev server, draws a seeded spread with the editor's SpreadView
|
||||
* and rasterises it with modern-screenshot. The image has to be a non-empty
|
||||
* JPEG at the size the Studio promises the server, and the map tile from
|
||||
* another server on the sheet must never have been fetched for it. Every
|
||||
* request to OpenStreetMap's tile servers is answered here, which keeps the
|
||||
* run off the network, but only the harness's own tile counts: the sheet's
|
||||
* <img> asks for it as an image, anything else would be the rasteriser
|
||||
* reaching for it. Its query string sets it apart from the tiles the app
|
||||
* itself may prefetch from the same servers. The specs block the service
|
||||
* worker, whose tile cache would answer before the route sees a request.
|
||||
*/
|
||||
export async function checkPageSnapshot(page: Page): Promise<void> {
|
||||
const tileFetches: string[] = [];
|
||||
await page.route(/^https:\/\/(?:[a-c]\.)?tile\.openstreetmap\.org\//, (route) => {
|
||||
const request = route.request();
|
||||
const kind = request.resourceType();
|
||||
if (request.url().startsWith(TILE) && kind !== 'image') tileFetches.push(kind);
|
||||
return route.fulfill({ status: 204, headers: { 'access-control-allow-origin': '*' } });
|
||||
});
|
||||
await page.goto('/dashboard');
|
||||
const shot = await page.evaluate(
|
||||
async ({ harness, tile }: { harness: string; tile: string }): Promise<SeededSnapshot | null> => {
|
||||
const mod = (await import(/* @vite-ignore */ harness)) as {
|
||||
snapshotSeededSpread: (tileUrl: string) => Promise<SeededSnapshot | null>;
|
||||
};
|
||||
return mod.snapshotSeededSpread(tile);
|
||||
},
|
||||
{ harness: '/e2e/support/pageSnapshotHarness.ts', tile: TILE }
|
||||
);
|
||||
|
||||
expect(shot, 'the spread was drawn and came out under the size limit').not.toBeNull();
|
||||
if (!shot) return;
|
||||
expect(shot.type).toBe('image/jpeg');
|
||||
expect(shot.head).toEqual([0xff, 0xd8, 0xff]);
|
||||
expect(shot.bytes).toBeGreaterThan(1_000);
|
||||
// A double spread at 1280 pixels on its long edge, and the file says so too.
|
||||
expect(shot.width).toBe(1280);
|
||||
expect(shot.height).toBe(640);
|
||||
expect(shot.decodedWidth).toBe(1280);
|
||||
expect(shot.decodedHeight).toBe(640);
|
||||
expect(shot.drawnPhotoIds).toEqual([]);
|
||||
expect(tileFetches, 'the tile from another server is left out before anything is fetched').toEqual([]);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { bookPageSetupForPreset, bookShapeElementSchema, bookTextElementSchema, type BookSpread } from '@trek/shared';
|
||||
import { createElement } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { SpreadView } from '../../src/components/Studio/SpreadView';
|
||||
import { sheetSizePx, snapshotSpread } from '../../src/components/Studio/pageSnapshot';
|
||||
|
||||
/**
|
||||
* Loaded by the page snapshot flows inside the running app, where the dev
|
||||
* server serves it as a module: draws a seeded spread with the editor's own
|
||||
* SpreadView and turns it into the JPEG the Studio sends the assistant, so a
|
||||
* real Chromium and a real WebKit prove the rasteriser works end to end. A map
|
||||
* tile from another server sits on the sheet and must be left out; the flow
|
||||
* names the tile and watches its server.
|
||||
*/
|
||||
|
||||
export interface SeededSnapshot {
|
||||
type: string;
|
||||
bytes: number;
|
||||
/** The first bytes of the file: a JPEG starts FF D8 FF. */
|
||||
head: number[];
|
||||
width: number;
|
||||
height: number;
|
||||
/** The image decoded again, as the browser reads it. */
|
||||
decodedWidth: number;
|
||||
decodedHeight: number;
|
||||
drawnPhotoIds: number[];
|
||||
}
|
||||
|
||||
function seededSpread(): BookSpread {
|
||||
return {
|
||||
id: 'sp-seeded',
|
||||
role: 'inner',
|
||||
background: '#f4efe6',
|
||||
elements: [
|
||||
bookShapeElementSchema.parse({ id: 'shape', kind: 'shape', frame: { x: 20, y: 20, w: 160, h: 90 } }),
|
||||
bookTextElementSchema.parse({
|
||||
id: 'title',
|
||||
kind: 'text',
|
||||
frame: { x: 230, y: 30, w: 160, h: 30 },
|
||||
text: 'Lisbon',
|
||||
}),
|
||||
],
|
||||
parked: [],
|
||||
entryId: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function snapshotSeededSpread(tileUrl: string): Promise<SeededSnapshot | null> {
|
||||
const page = bookPageSetupForPreset('square-210');
|
||||
const spread = seededSpread();
|
||||
const size = sheetSizePx(spread, page);
|
||||
// Off screen as the Studio's stage is; the sheet inside it is what is drawn.
|
||||
const host = document.createElement('div');
|
||||
host.style.cssText = 'position:fixed;top:0;inset-inline-start:-20000px';
|
||||
const sheet = document.createElement('div');
|
||||
sheet.style.cssText = `position:relative;width:${size.width}px;height:${size.height}px`;
|
||||
host.appendChild(sheet);
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(sheet);
|
||||
flushSync(() => root.render(createElement(SpreadView, { spread, page, print: true })));
|
||||
const tile = document.createElement('img');
|
||||
tile.src = tileUrl;
|
||||
sheet.firstElementChild?.appendChild(tile);
|
||||
try {
|
||||
const shot = await snapshotSpread(sheet, size, new Set());
|
||||
if (!shot) return null;
|
||||
const head = [...new Uint8Array(await shot.image.slice(0, 3).arrayBuffer())];
|
||||
const bitmap = await createImageBitmap(shot.image);
|
||||
return {
|
||||
type: shot.image.type,
|
||||
bytes: shot.image.size,
|
||||
head,
|
||||
width: shot.width,
|
||||
height: shot.height,
|
||||
decodedWidth: bitmap.width,
|
||||
decodedHeight: bitmap.height,
|
||||
drawnPhotoIds: shot.drawnPhotoIds,
|
||||
};
|
||||
} finally {
|
||||
root.unmount();
|
||||
host.remove();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "4.3.3",
|
||||
"version": "4.3.4",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
@@ -46,14 +46,42 @@
|
||||
"predev": "node scripts/patch-maplibre.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
"@fontsource/abril-fatface": "^5.3.0",
|
||||
"@fontsource/alfa-slab-one": "^5.3.0",
|
||||
"@fontsource/allura": "^5.3.0",
|
||||
"@fontsource/anton": "^5.3.0",
|
||||
"@fontsource/archivo-black": "^5.3.0",
|
||||
"@fontsource/bebas-neue": "^5.3.0",
|
||||
"@fontsource/big-shoulders-display": "^5.3.0",
|
||||
"@fontsource/bodoni-moda": "^5.3.0",
|
||||
"@fontsource/bricolage-grotesque": "^5.3.0",
|
||||
"@fontsource/caveat": "^5.3.0",
|
||||
"@fontsource/cormorant-garamond": "^5.3.0",
|
||||
"@fontsource/dancing-script": "^5.3.0",
|
||||
"@fontsource/dm-mono": "^5.3.0",
|
||||
"@fontsource/dm-serif-display": "^5.3.0",
|
||||
"@fontsource/eb-garamond": "^5.3.0",
|
||||
"@fontsource/fraunces": "^5.3.0",
|
||||
"@fontsource/geist-sans": "^5.3.0",
|
||||
"@fontsource/great-vibes": "^5.3.0",
|
||||
"@fontsource/homemade-apple": "^5.3.0",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@fontsource/kaushan-script": "^5.3.0",
|
||||
"@fontsource/league-gothic": "^5.3.0",
|
||||
"@fontsource/lora": "^5.3.0",
|
||||
"@fontsource/montserrat": "^5.3.0",
|
||||
"@fontsource/museomoderno": "^5.3.0",
|
||||
"@fontsource/oswald": "^5.3.0",
|
||||
"@fontsource/pacifico": "^5.3.0",
|
||||
"@fontsource/parisienne": "^5.3.0",
|
||||
"@fontsource/permanent-marker": "^5.3.0",
|
||||
"@fontsource/playfair-display": "^5.3.0",
|
||||
"@fontsource/poppins": "^5.3.0",
|
||||
"@fontsource/sacramento": "^5.3.0",
|
||||
"@fontsource/satisfy": "^5.3.0",
|
||||
"@fontsource/shrikhand": "^5.3.0",
|
||||
"@fontsource/yellowtail": "^5.3.0",
|
||||
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
@@ -67,6 +95,7 @@
|
||||
"lucide-react": "^0.577.0",
|
||||
"mapbox-gl": "^3.31.0",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"modern-screenshot": "4.7.0",
|
||||
"plyr": "^3.8.4",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
@@ -79,7 +108,7 @@
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tz-lookup": "^6.1.25",
|
||||
"zod": "^4.6.5",
|
||||
"zod": "^4.6.7",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 29 KiB |
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 404,
|
||||
"@typescript-eslint/no-misused-promises": 858,
|
||||
"@typescript-eslint/no-non-null-assertion": 384,
|
||||
"@typescript-eslint/no-misused-promises": 849,
|
||||
"no-console": 88,
|
||||
"complexity": 229,
|
||||
"complexity": 222,
|
||||
"@typescript-eslint/no-explicit-any": 146,
|
||||
"trek/no-swallowed-catch": 133,
|
||||
"max-params": 35,
|
||||
"trek/no-swallowed-catch": 130,
|
||||
"max-params": 32,
|
||||
"trek/store-without-selector": 67,
|
||||
"react-hooks/refs": 557,
|
||||
"react-hooks/set-state-in-effect": 196,
|
||||
"@typescript-eslint/no-floating-promises": 167,
|
||||
"react-hooks/exhaustive-deps": 120,
|
||||
"@typescript-eslint/no-unused-vars": 126,
|
||||
"react-hooks/refs": 556,
|
||||
"react-hooks/set-state-in-effect": 194,
|
||||
"@typescript-eslint/no-floating-promises": 165,
|
||||
"react-hooks/exhaustive-deps": 116,
|
||||
"@typescript-eslint/no-unused-vars": 124,
|
||||
"react-hooks/immutability": 28,
|
||||
"max-lines-per-function": 133,
|
||||
"max-lines-per-function": 130,
|
||||
"react-hooks/static-components": 78,
|
||||
"react-hooks/preserve-manual-memoization": 12,
|
||||
"no-useless-assignment": 7,
|
||||
"no-empty": 19,
|
||||
"trek/no-window-globals": 41,
|
||||
"react-refresh/only-export-components": 46,
|
||||
"react-refresh/only-export-components": 45,
|
||||
"unused-disable-directive": 6,
|
||||
"max-depth": 21,
|
||||
"react-hooks/void-use-memo": 1,
|
||||
@@ -34,12 +34,12 @@
|
||||
"react-hooks/exhaustive-deps": 46
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 491,
|
||||
"@typescript-eslint/no-non-null-assertion": 1943,
|
||||
"@typescript-eslint/no-explicit-any": 490,
|
||||
"@typescript-eslint/no-non-null-assertion": 1929,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
"trek/no-window-globals": 332,
|
||||
"trek/no-window-globals": 331,
|
||||
"trek/no-swallowed-catch": 14,
|
||||
"react-hooks/globals": 5,
|
||||
"react-hooks/refs": 22,
|
||||
|
||||
@@ -16,12 +16,17 @@
|
||||
"feature": "Addon manager",
|
||||
"hooks": [
|
||||
"src/components/Admin/addons/addonModel.ts",
|
||||
"src/components/Admin/addons/useAddonManager.ts",
|
||||
"src/components/Admin/addons/useLlmParsingConfig.ts"
|
||||
"src/components/Admin/addons/useAddonManager.ts"
|
||||
],
|
||||
"desktop": ["src/components/Admin/AddonManager.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminAddonManager.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Addon manager: AI tile",
|
||||
"hooks": ["src/components/Admin/addons/useAiAddonTile.ts"],
|
||||
"desktop": ["src/components/Admin/addons/AiAddonTile.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminAddonManager.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Admin notification preference matrix",
|
||||
"hooks": ["src/components/Admin/useAdminNotificationMatrix.ts", "src/pages/admin/AdminPage.constants.ts"],
|
||||
@@ -58,6 +63,30 @@
|
||||
"desktop": ["src/pages/admin/AdminUsersTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminUsersSection.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "AI assistant chat",
|
||||
"hooks": ["src/components/Assistant/useAssistantThread.ts"],
|
||||
"desktop": ["src/components/Assistant/AssistantWindow.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/assistant/MAssistantScreen.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "AI assistant home chat",
|
||||
"hooks": ["src/components/Assistant/useAssistantHome.ts"],
|
||||
"desktop": ["src/components/Assistant/AssistantWindow.tsx"],
|
||||
"mobile": [
|
||||
"src/mobile/screens/assistant/MAssistantHomeScreen.tsx",
|
||||
"src/mobile/screens/dashboard/MDashboard.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Assistant hints",
|
||||
"hooks": ["src/components/Assistant/useAssistantHints.ts"],
|
||||
"desktop": ["src/components/Assistant/AssistantHeader.tsx", "src/components/Assistant/AssistantLauncher.tsx"],
|
||||
"mobile": [
|
||||
"src/mobile/screens/trip/assistant/MAssistantScreen.tsx",
|
||||
"src/mobile/screens/trip/sheets/MMehrSheet.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Atlas country/region action sheet",
|
||||
"hooks": ["src/pages/atlas/useAtlasCountryActions.ts"],
|
||||
@@ -206,8 +235,7 @@
|
||||
"src/components/Collections/CollectionPicker.tsx",
|
||||
"src/components/Collections/CollectionPlaceDetail.tsx",
|
||||
"src/components/Collections/ListEditorModal.tsx",
|
||||
"src/components/Collections/MSaveToCollectionSheet.tsx",
|
||||
"src/components/Collections/StatusBadge.tsx"
|
||||
"src/components/Collections/MSaveToCollectionSheet.tsx"
|
||||
],
|
||||
"mobile": [
|
||||
"src/mobile/screens/collections/MCollAddSheet.tsx",
|
||||
@@ -245,8 +273,8 @@
|
||||
"mobile": ["src/mobile/screens/trip/sheets/MCostSheet.tsx", "src/mobile/screens/trip/tabs/MCostsTab.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Costs/budget ledger: useCostsLedger, useSettlementForm",
|
||||
"hooks": ["src/components/Budget/useCostsLedger.ts", "src/components/Budget/useSettlementForm.ts"],
|
||||
"feature": "Costs/budget ledger: useCostsLedger, useSettlementForm, useExpenseLinks",
|
||||
"hooks": ["src/components/Budget/useCostsLedger.ts", "src/components/Budget/useSettlementForm.ts", "src/components/Budget/useExpenseLinks.ts"],
|
||||
"desktop": ["src/components/Budget/CostsPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/tabs/MCostsTab.tsx"]
|
||||
},
|
||||
@@ -573,7 +601,6 @@
|
||||
"hooks": ["src/components/Planner/usePlaceActions.ts"],
|
||||
"desktop": [
|
||||
"src/components/Planner/PlaceInspector.tsx",
|
||||
"src/components/shared/PlaceAvatarUpload.tsx",
|
||||
"src/pages/tripPlanner/useTripPlannerPage.ts"
|
||||
],
|
||||
"mobile": ["src/mobile/screens/trip/sheets/MPlaceSheet.tsx"]
|
||||
@@ -658,6 +685,12 @@
|
||||
"desktop": ["src/components/Settings/DisplaySettingsTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MSettingsGeneral.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Integrations / AI assistant",
|
||||
"hooks": ["src/components/Settings/ai/useAiConnections.ts"],
|
||||
"desktop": ["src/components/Settings/ai/AiConnectionSection.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MAiConnectionSection.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Integrations / AI parsing",
|
||||
"hooks": ["src/components/Settings/useLlmConnection.ts"],
|
||||
@@ -976,7 +1009,6 @@
|
||||
"src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": "Phone action sheet for a pool place that calls planner handlers and the shared placeToSaveTarget.",
|
||||
"src/mobile/screens/trip/sheets/MDaysSheet.tsx": "Button based day reorder sheet over planner.handleReorderDays; the desktop reorders by drag.",
|
||||
"src/mobile/screens/trip/sheets/MLinkedCosts.tsx": "Already shares useExpenseLinks with the desktop BookingCostsSection; only markup and a search filter are local.",
|
||||
"src/mobile/screens/trip/sheets/MMehrSheet.tsx": "Phone dock overflow menu that only opens tabs and sheets.",
|
||||
"src/mobile/screens/trip/sheets/MTripSheetUi.tsx": "Phone sheet UI primitives (eyebrow, headers, stat boxes, action circles) and a time display helper.",
|
||||
"src/mobile/screens/trip/sheets/PlFileAttach.tsx": "Markup only; pending files and link actions come through props from the phone form sheets.",
|
||||
"src/mobile/screens/trip/sheets/PlSheetChrome.tsx": "Phone form sheet chrome primitives (header, footer, field classes).",
|
||||
|
||||
@@ -400,8 +400,6 @@
|
||||
"src/components/Journey/moodConfig.ts",
|
||||
"src/components/Journey/photoZoom.test.ts",
|
||||
"src/components/Journey/photoZoom.ts",
|
||||
"src/components/Journey/stripMarkdown.test.ts",
|
||||
"src/components/Journey/stripMarkdown.ts",
|
||||
"src/components/Journey/useConnectedPhotoProviders.test.tsx",
|
||||
"src/components/Journey/useConnectedPhotoProviders.ts",
|
||||
"src/components/Journey/useEntryPhotoOrder.test.ts",
|
||||
@@ -729,8 +727,6 @@
|
||||
"src/components/Planner/parsedItemToDraft.ts",
|
||||
"src/components/Planner/placeAmap.test.ts",
|
||||
"src/components/Planner/placeAmap.ts",
|
||||
"src/components/Planner/placeCategoryGuess.test.ts",
|
||||
"src/components/Planner/placeCategoryGuess.ts",
|
||||
"src/components/Planner/placeCoMaps.test.ts",
|
||||
"src/components/Planner/placeCoMaps.ts",
|
||||
"src/components/Planner/placeDialogParts.test.tsx",
|
||||
@@ -783,7 +779,6 @@
|
||||
"src/components/Roadtrip/AutomaticDayStop.test.tsx",
|
||||
"src/components/Roadtrip/AutomaticDayStop.tsx",
|
||||
"src/components/Roadtrip/ChargingInfo.test.tsx",
|
||||
"src/components/Roadtrip/ChargingInfo.tsx",
|
||||
"src/components/Roadtrip/DayWindowFields.test.tsx",
|
||||
"src/components/Roadtrip/DayWindowFields.tsx",
|
||||
"src/components/Roadtrip/EndDayControl.test.tsx",
|
||||
@@ -975,19 +970,13 @@
|
||||
"src/components/Studio/TravelElements.tsx",
|
||||
"src/components/Studio/TravelPreview.tsx",
|
||||
"src/components/Studio/TrimField.tsx",
|
||||
"src/components/Studio/applyTemplate.ts",
|
||||
"src/components/Studio/autoLayout.ts",
|
||||
"src/components/Studio/bookFontFaces.ts",
|
||||
"src/components/Studio/bookFonts.ts",
|
||||
"src/components/Studio/bookIds.ts",
|
||||
"src/components/Studio/bookRender.ts",
|
||||
"src/components/Studio/bookSheets.ts",
|
||||
"src/components/Studio/bookTemplates.data.ts",
|
||||
"src/components/Studio/countryShapes.ts",
|
||||
"src/components/Studio/entryText.ts",
|
||||
"src/components/Studio/flags.ts",
|
||||
"src/components/Studio/folioColour.ts",
|
||||
"src/components/Studio/grids.ts",
|
||||
"src/components/Studio/iconLibrary.ts",
|
||||
"src/components/Studio/mapSources.ts",
|
||||
"src/components/Studio/mapTiles.ts",
|
||||
@@ -997,13 +986,10 @@
|
||||
"src/components/Studio/printSheets.ts",
|
||||
"src/components/Studio/resolveBindings.ts",
|
||||
"src/components/Studio/roadRoute.ts",
|
||||
"src/components/Studio/savedLayouts.ts",
|
||||
"src/components/Studio/shapes.ts",
|
||||
"src/components/Studio/spreadFile.ts",
|
||||
"src/components/Studio/studioClipboard.ts",
|
||||
"src/components/Studio/studioUpload.ts",
|
||||
"src/components/Studio/templates.ts",
|
||||
"src/components/Studio/travelRefresh.ts",
|
||||
"src/components/Studio/useBookPresence.ts",
|
||||
"src/components/Studio/useBookStore.ts",
|
||||
"src/components/Studio/useSpreadInteraction.ts",
|
||||
@@ -1137,8 +1123,6 @@
|
||||
"src/components/shared/PasswordChecklist.tsx",
|
||||
"src/components/shared/PlaceAvatar.test.tsx",
|
||||
"src/components/shared/PlaceAvatar.tsx",
|
||||
"src/components/shared/PlaceAvatarUpload.test.tsx",
|
||||
"src/components/shared/PlaceAvatarUpload.tsx",
|
||||
"src/components/shared/PluginIcon.test.tsx",
|
||||
"src/components/shared/PluginIcon.tsx",
|
||||
"src/components/shared/PublicLanguagePicker.test.tsx",
|
||||
@@ -2116,7 +2100,6 @@
|
||||
"tests/unit/studio/entryText.test.ts",
|
||||
"tests/unit/studio/flags.test.ts",
|
||||
"tests/unit/studio/folioColour.test.ts",
|
||||
"tests/unit/studio/grids.test.ts",
|
||||
"tests/unit/studio/iconElement.test.tsx",
|
||||
"tests/unit/studio/inspector.test.tsx",
|
||||
"tests/unit/studio/inspectorTravel.test.tsx",
|
||||
@@ -2140,7 +2123,6 @@
|
||||
"tests/unit/studio/studioClipboardLayouts.test.ts",
|
||||
"tests/unit/studio/studioStore.spreads.test.ts",
|
||||
"tests/unit/studio/studioUpload.test.ts",
|
||||
"tests/unit/studio/templates.test.ts",
|
||||
"tests/unit/studio/travelRefresh.test.ts",
|
||||
"tests/unit/studio/travelStops.test.tsx",
|
||||
"tests/unit/studio/trimField.test.tsx",
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
*
|
||||
* The en keys nothing reaches are a ratchet. Besides src/, the search for
|
||||
* references reads server/src (system notice and notification channel keys
|
||||
* the server sends, *Key properties in its registries) and plugin-sdk/src, so
|
||||
* the server sends, *Key properties in its registries), plugin-sdk/src and
|
||||
* shared/src/book (the Studio's layout template names), so
|
||||
* a key the server uses does not read as dead; those references only count as
|
||||
* reached, they are not held to the rules above. The unused keys per en
|
||||
* domain file may not rise above scripts/i18n-unused-baseline.json, and an
|
||||
@@ -69,11 +70,15 @@ const UNUSED_BASELINE = join(HERE, 'i18n-unused-baseline.json');
|
||||
|
||||
/**
|
||||
* Where else a translation key can be named: the server sends keys for the
|
||||
* client to translate (system notices, notification channels), and the plugin
|
||||
* SDK names the ones plugin surfaces use. Read only to find which en keys are
|
||||
* reached.
|
||||
* client to translate (system notices, notification channels), the plugin
|
||||
* SDK names the ones plugin surfaces use, and the shared book engine names
|
||||
* its layout templates. Read only to find which en keys are reached.
|
||||
*/
|
||||
export const REFERENCE_ROOTS = [join(HERE, '..', '..', 'server', 'src'), join(HERE, '..', '..', 'plugin-sdk', 'src')];
|
||||
export const REFERENCE_ROOTS = [
|
||||
join(HERE, '..', '..', 'server', 'src'),
|
||||
join(HERE, '..', '..', 'plugin-sdk', 'src'),
|
||||
join(HERE, '..', '..', 'shared', 'src', 'book'),
|
||||
];
|
||||
|
||||
/** How many en keys a template may reach and still pass without an entry below. */
|
||||
export const MAX_IMPLICIT_MATCHES = 30;
|
||||
@@ -112,11 +117,6 @@ export const DYNAMIC_ALLOWED = [
|
||||
template: 'settings.${mode}',
|
||||
because: "mode is a ThemeMode from resolveMode(), 'light' | 'dark' | 'auto', and settings.light/dark/auto exist",
|
||||
},
|
||||
...['components/Studio/StudioElementsPanel.tsx', 'components/Studio/StudioInspector.tsx'].map((file) => ({
|
||||
file,
|
||||
template: 'journey.studio.${key}',
|
||||
because: 'key comes from an `as const` tuple of frame and stroke styles written beside the call, typed literals',
|
||||
})),
|
||||
{
|
||||
file: 'mobile/screens/trip/sheets/MBookingFields.tsx',
|
||||
template: 'reservations.${s}',
|
||||
@@ -139,6 +139,20 @@ export const DYNAMIC_ALLOWED = [
|
||||
'memories.* key in en',
|
||||
}))
|
||||
),
|
||||
{
|
||||
file: 'components/Studio/stickerLibrary.ts',
|
||||
template: 'journey.studio.sticker.${id}',
|
||||
because:
|
||||
'id is a sticker id of the library in @trek/shared/stickers; stickerLibrary.test.ts resolves the key of every ' +
|
||||
'sticker the library holds against en',
|
||||
},
|
||||
{
|
||||
file: 'components/Studio/shapes.ts',
|
||||
template: 'journey.studio.shapeName.${shape}',
|
||||
because:
|
||||
'shapeName takes a BookShapeId; shared/src/book/book-shape-catalogue.spec.ts holds an en name for every ' +
|
||||
'shape of BOOK_SHAPE_GROUPS',
|
||||
},
|
||||
];
|
||||
|
||||
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"admin.ts": 126,
|
||||
"atlas.ts": 10,
|
||||
"atlas.ts": 9,
|
||||
"backup.ts": 10,
|
||||
"budget.ts": 40,
|
||||
"budget.ts": 36,
|
||||
"categories.ts": 1,
|
||||
"collab.ts": 9,
|
||||
"collection.ts": 35,
|
||||
@@ -14,7 +14,7 @@
|
||||
"files.ts": 4,
|
||||
"help.ts": 3,
|
||||
"inspector.ts": 4,
|
||||
"journey.ts": 69,
|
||||
"journey.ts": 65,
|
||||
"login.ts": 1,
|
||||
"map.ts": 5,
|
||||
"memories.ts": 8,
|
||||
@@ -26,16 +26,16 @@
|
||||
"packing.ts": 6,
|
||||
"pdf.ts": 1,
|
||||
"perm.ts": 4,
|
||||
"places.ts": 10,
|
||||
"places.ts": 9,
|
||||
"planner.ts": 53,
|
||||
"reservations.ts": 21,
|
||||
"roadtrip.ts": 38,
|
||||
"settings.ts": 40,
|
||||
"roadtrip.ts": 36,
|
||||
"settings.ts": 37,
|
||||
"share.ts": 7,
|
||||
"shared.ts": 2,
|
||||
"storage.ts": 16,
|
||||
"system_notice.ts": 32,
|
||||
"todo.ts": 4,
|
||||
"todo.ts": 3,
|
||||
"tours.ts": 12,
|
||||
"trip.ts": 10,
|
||||
"undo.ts": 1,
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"src/components/Collections/CollectionPlaceDetail.tsx": 1,
|
||||
"src/components/Collections/ListEditorModal.tsx": 1,
|
||||
"src/components/Collections/MSaveToCollectionSheet.tsx": 3,
|
||||
"src/components/Collections/StatusBadge.tsx": 1,
|
||||
"src/components/Dawarich/DawarichAcceptDialog.tsx": 3,
|
||||
"src/components/Dawarich/DawarichAtlasDialog.tsx": 3,
|
||||
"src/components/Dawarich/DawarichSuggestionsPanel.tsx": 1,
|
||||
@@ -39,8 +38,8 @@
|
||||
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
|
||||
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
|
||||
"src/components/Studio/StudioShell.tsx": 1,
|
||||
"src/components/Studio/StudioSidebar.tsx": 1,
|
||||
"src/components/Studio/TravelElements.tsx": 1,
|
||||
"src/components/Studio/sidebar/StudioEntryCard.tsx": 1,
|
||||
"src/components/Studio/travel/BadgeIcon.tsx": 1,
|
||||
"src/components/Todo/useTodoList.ts": 1,
|
||||
"src/components/shared/ConfirmDialog.tsx": 1,
|
||||
"src/components/shared/CountryFlag.tsx": 1,
|
||||
@@ -56,6 +55,6 @@
|
||||
"src/hooks/useWebPush.ts": 1,
|
||||
"src/repo/pluginPoiRepo.ts": 1,
|
||||
"src/store/slices/budgetSlice.ts": 1,
|
||||
"src/store/studioStore.ts": 2,
|
||||
"src/store/studioStore.ts": 1,
|
||||
"src/store/vacayStore.ts": 1
|
||||
}
|
||||
|
||||
@@ -31,11 +31,15 @@ export async function isFormatted(path) {
|
||||
if (!options) throw new RatchetError(`no Prettier config applies to ${path}`);
|
||||
try {
|
||||
return await prettier.check(readText(path), { ...options, filepath: path });
|
||||
} catch {
|
||||
} catch (err) {
|
||||
stopped.set(path, String(err instanceof Error ? err.message : err).split('\n')[0]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Why Prettier gave up on a file, by its path, so the failure line can say it was not about style. */
|
||||
const stopped = new Map();
|
||||
|
||||
/** The files under root that Prettier would rewrite, keyed by their path from root. */
|
||||
export async function unformatted(root) {
|
||||
const found = [];
|
||||
@@ -74,6 +78,8 @@ export async function check({
|
||||
if (list) for (const key of found) log(`${allowed.has(key) ? 'listed' : 'new '} ${key}`);
|
||||
for (const key of fresh) {
|
||||
error(`FAIL ${key} is not formatted. Run: npx prettier --write ${key}`);
|
||||
const why = stopped.get(join(root, key));
|
||||
if (why) error(` Prettier could not format it: ${why}`);
|
||||
}
|
||||
for (const key of stale) {
|
||||
error(`FAIL ${key} is listed in scripts/format-baseline.json but is formatted now or gone.`);
|
||||
|
||||
@@ -116,7 +116,6 @@
|
||||
"src/components/Vacay/useVacayCalendarLogic.ts",
|
||||
"src/components/Vacay/useVacaySettings.ts",
|
||||
"src/components/Vacay/useVacayUserPicker.ts",
|
||||
"src/components/shared/PlaceAvatarUpload.tsx",
|
||||
"src/hooks/useAirtrailConnection.ts",
|
||||
"src/hooks/useDawarichConnection.ts",
|
||||
"src/hooks/useDawarichSuggestions.ts",
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"components/Collab/CollabChatEmojiPicker.tsx": 3,
|
||||
"components/Collab/CollabChatReactionBadge.tsx": 3,
|
||||
"components/Collab/CollabChatReactionMenu.tsx": 1,
|
||||
"components/Collab/CollabPolls.tsx": 1,
|
||||
"components/Collections/CollectionPlaceDetail.tsx": 2,
|
||||
"components/Files/FileManagerAvatarChip.tsx": 3,
|
||||
"components/Journey/JourneyDayJump.tsx": 1,
|
||||
@@ -19,7 +18,6 @@
|
||||
"components/Journey/JourneyMap.tsx": 1,
|
||||
"components/Journey/JourneyMapGL.tsx": 2,
|
||||
"components/Journey/MobileEntryView.tsx": 1,
|
||||
"components/Journey/PhotoLightbox.tsx": 3,
|
||||
"components/Journey/journeyPhotoLayer.ts": 1,
|
||||
"components/Layout/Navbar.tsx": 1,
|
||||
"components/Layout/OfflineBanner.tsx": 1,
|
||||
@@ -53,13 +51,12 @@
|
||||
"components/Studio/BookSheetsView.tsx": 3,
|
||||
"components/Studio/PageNumbers.tsx": 1,
|
||||
"components/Studio/PeerCursors.tsx": 1,
|
||||
"components/Studio/SpreadView.tsx": 8,
|
||||
"components/Studio/StudioCanvas.tsx": 22,
|
||||
"components/Studio/PhotoView.tsx": 4,
|
||||
"components/Studio/SpreadView.tsx": 4,
|
||||
"components/Studio/StudioCanvas.tsx": 12,
|
||||
"components/Studio/StudioElementsPanel.tsx": 1,
|
||||
"components/Studio/StudioExport.tsx": 1,
|
||||
"components/Studio/StudioInspector.tsx": 1,
|
||||
"components/Studio/StudioSavedLayouts.tsx": 1,
|
||||
"components/Studio/StudioSidebar.tsx": 3,
|
||||
"components/Studio/TravelElements.tsx": 4,
|
||||
"components/Studio/TravelPreview.tsx": 1,
|
||||
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 3,
|
||||
@@ -74,7 +71,6 @@
|
||||
"components/shared/DetailShell.tsx": 1,
|
||||
"components/shared/ElevationProfile.tsx": 2,
|
||||
"components/shared/NavigationMenu.tsx": 2,
|
||||
"components/shared/PlaceAvatarUpload.tsx": 3,
|
||||
"components/shared/PublicLanguagePicker.tsx": 1,
|
||||
"components/shared/SlidingTabs.tsx": 3,
|
||||
"components/shared/StarRating.tsx": 3,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"pages/tripPlanner/PlanMapStage.tsx": 4,
|
||||
"mobile/components/trek3d/MascotClip.tsx": 1
|
||||
"components/Studio/LineHandles.tsx": 1,
|
||||
"components/Studio/StudioSelectionChrome.tsx": 2,
|
||||
"components/Studio/inspector/FocalPad.tsx": 1,
|
||||
"mobile/components/trek3d/MascotClip.tsx": 1,
|
||||
"pages/tripPlanner/PlanMapStage.tsx": 4
|
||||
}
|
||||
|
||||
@@ -4,29 +4,27 @@
|
||||
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2736,
|
||||
"src/components/Budget/CostsPanel.tsx": 1804,
|
||||
"src/components/Map/MapView.tsx": 1401,
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1399,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2920,
|
||||
"src/components/Map/MapViewGL.tsx": 2293,
|
||||
"src/components/Map/RouteCalculator.ts": 1195,
|
||||
"src/components/Map/MapViewGL.tsx": 2290,
|
||||
"src/components/Map/RouteCalculator.ts": 1076,
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2953,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2936,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1193,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1304,
|
||||
"src/components/Studio/TravelElements.tsx": 1738,
|
||||
"src/components/Studio/autoLayout.ts": 1691,
|
||||
"src/components/Studio/bookTemplates.data.ts": 1309,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1157,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1290,
|
||||
"src/components/Studio/TravelElements.tsx": 1559,
|
||||
"src/components/Studio/countryShapes.ts": 2595,
|
||||
"src/index.css": 1636,
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4030,
|
||||
"src/pages/LoginPage.tsx": 1348,
|
||||
"src/pages/TripPlannerPage.tsx": 1131,
|
||||
"src/pages/TripPlannerPage.tsx": 1128,
|
||||
"src/pages/atlas/useAtlas.ts": 1211,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
|
||||
"src/styles/studio.css": 2087
|
||||
"src/styles/studio.css": 1918
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"src/components/Collab/CollabChat.helpers.ts": 1,
|
||||
"src/components/Collab/CollabChat.tsx": 3,
|
||||
"src/components/Collab/CollabChatEmojiPicker.tsx": 5,
|
||||
"src/components/Collab/CollabChatMessageText.tsx": 2,
|
||||
"src/components/Collab/CollabChatMessages.tsx": 4,
|
||||
"src/components/Collab/CollabChatReactionBadge.tsx": 1,
|
||||
"src/components/Collab/CollabChatReactionMenu.tsx": 2,
|
||||
@@ -52,7 +51,7 @@
|
||||
"src/components/Planner/DayPlanSidebarNoteModal.test.tsx": 1,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 3,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1,
|
||||
"src/components/Planner/PlaceInspector.tsx": 17,
|
||||
"src/components/Planner/PlaceInspector.tsx": 16,
|
||||
"src/components/Planner/PlacesBulkCategoryModal.test.tsx": 1,
|
||||
"src/components/Planner/PlacesSidebar.tsx": 1,
|
||||
"src/components/Planner/PlacesSidebarMobileDayPicker.tsx": 8,
|
||||
@@ -83,9 +82,6 @@
|
||||
"src/components/Settings/NotificationsTab.test.tsx": 1,
|
||||
"src/components/Settings/PasskeysSection.tsx": 1,
|
||||
"src/components/Settings/PhotoProvidersSection.test.tsx": 2,
|
||||
"src/components/Studio/StudioCanvas.tsx": 1,
|
||||
"src/components/Studio/StudioInspector.tsx": 1,
|
||||
"src/components/Studio/autoLayout.ts": 1,
|
||||
"src/components/Todo/todoListModel.ts": 1,
|
||||
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
|
||||
"src/components/Tours/planner/useTourPlanner.ts": 2,
|
||||
@@ -100,7 +96,6 @@
|
||||
"src/components/shared/CustomSelect.test.tsx": 1,
|
||||
"src/components/shared/PlaceAvatar.test.tsx": 2,
|
||||
"src/components/shared/PlaceAvatar.tsx": 1,
|
||||
"src/components/shared/PlaceAvatarUpload.test.tsx": 1,
|
||||
"src/hooks/useDawarichSuggestions.test.ts": 1,
|
||||
"src/mobile/screens/admin/MAdminAddonManager.tsx": 2,
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 6,
|
||||
@@ -145,7 +140,6 @@
|
||||
"tests/integration/api/client.test.ts": 1,
|
||||
"tests/integration/hooks/useRouteCalculation.test.ts": 1,
|
||||
"tests/unit/i18n/i18nKeysScript.test.ts": 3,
|
||||
"tests/unit/mobile/admin/MAdminAddonManager.test.tsx": 2,
|
||||
"tests/unit/mobile/admin/MAdminGitHubPanel.test.tsx": 1,
|
||||
"tests/unit/mobile/admin/MAdminNotifyMatrix.test.tsx": 1,
|
||||
"tests/unit/mobile/admin/MAdminStoragePanel.test.tsx": 1,
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"components/Journey/moodConfig.ts": 5,
|
||||
"components/Layout/BottomNav.tsx": 3,
|
||||
"components/Layout/DemoBanner.tsx": 25,
|
||||
"components/Layout/InAppNotificationBell.tsx": 8,
|
||||
"components/Layout/InAppNotificationBell.tsx": 3,
|
||||
"components/Layout/MobileTopBar.tsx": 51,
|
||||
"components/Layout/Navbar.tsx": 21,
|
||||
"components/Layout/NewVersionNotice.tsx": 3,
|
||||
@@ -72,7 +72,7 @@
|
||||
"components/Map/poiCategories.ts": 7,
|
||||
"components/Map/ratingBadge.ts": 2,
|
||||
"components/Map/reservationsMapbox.ts": 1,
|
||||
"components/Notifications/InAppNotificationItem.tsx": 8,
|
||||
"components/Notifications/InAppNotificationItem.tsx": 1,
|
||||
"components/PDF/TripPDF.tsx": 53,
|
||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||
@@ -100,15 +100,13 @@
|
||||
"components/Roadtrip/serviceMarker.ts": 1,
|
||||
"components/Settings/useAppearanceEditor.ts": 5,
|
||||
"components/Studio/BookSheetsView.tsx": 2,
|
||||
"components/Studio/SpreadView.tsx": 3,
|
||||
"components/Studio/PhotoView.tsx": 2,
|
||||
"components/Studio/SpreadView.tsx": 1,
|
||||
"components/Studio/StudioElementsPanel.tsx": 8,
|
||||
"components/Studio/StudioInspector.tsx": 2,
|
||||
"components/Studio/StudioShell.tsx": 2,
|
||||
"components/Studio/StudioSidebar.tsx": 2,
|
||||
"components/Studio/StudioTravelPanel.tsx": 1,
|
||||
"components/Studio/TravelElements.tsx": 5,
|
||||
"components/Studio/printSheets.ts": 5,
|
||||
"components/Studio/templates.ts": 3,
|
||||
"components/Studio/sidebar/contentDrops.ts": 2,
|
||||
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 4,
|
||||
"components/SystemNotices/SystemNoticeBanner.tsx": 28,
|
||||
"components/SystemNotices/SystemNoticeModal.tsx": 107,
|
||||
@@ -134,7 +132,6 @@
|
||||
"components/shared/Modal.tsx": 3,
|
||||
"components/shared/NavigationMenu.tsx": 1,
|
||||
"components/shared/NoteColorPicker.tsx": 2,
|
||||
"components/shared/PlaceAvatarUpload.tsx": 3,
|
||||
"components/shared/PublicLanguagePicker.tsx": 3,
|
||||
"components/shared/Skeleton.tsx": 2,
|
||||
"components/shared/Spinner.tsx": 2,
|
||||
@@ -154,7 +151,7 @@
|
||||
"mobile/components/MToastHost.tsx": 2,
|
||||
"mobile/components/MUserPickerList.tsx": 1,
|
||||
"mobile/screens/admin/MAdmin.tsx": 9,
|
||||
"mobile/screens/admin/MAdminAddonManager.tsx": 22,
|
||||
"mobile/screens/admin/MAdminAddonManager.tsx": 9,
|
||||
"mobile/screens/admin/MAdminAuditLogPanel.tsx": 15,
|
||||
"mobile/screens/admin/MAdminBackupPanel.tsx": 8,
|
||||
"mobile/screens/admin/MAdminCategoryManager.tsx": 8,
|
||||
@@ -254,7 +251,7 @@
|
||||
"mobile/screens/trip/sheets/MMehrSheet.tsx": 3,
|
||||
"mobile/screens/trip/sheets/MNoteSheet.tsx": 1,
|
||||
"mobile/screens/trip/sheets/MPlaceEditSheet.tsx": 2,
|
||||
"mobile/screens/trip/sheets/MPlaceSheet.tsx": 27,
|
||||
"mobile/screens/trip/sheets/MPlaceSheet.tsx": 23,
|
||||
"mobile/screens/trip/sheets/MReservationSheet.tsx": 2,
|
||||
"mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9,
|
||||
"mobile/screens/trip/sheets/MTransportSheet.tsx": 12,
|
||||
@@ -298,7 +295,7 @@
|
||||
"pages/FilesPage.tsx": 11,
|
||||
"pages/ForgotPasswordPage.tsx": 18,
|
||||
"pages/HelpPage.tsx": 21,
|
||||
"pages/InAppNotificationsPage.tsx": 9,
|
||||
"pages/InAppNotificationsPage.tsx": 2,
|
||||
"pages/JoinTripPage.tsx": 4,
|
||||
"pages/JourneyDetailPage.tsx": 53,
|
||||
"pages/JourneyPage.tsx": 64,
|
||||
@@ -310,7 +307,6 @@
|
||||
"pages/TripPlannerPage.tsx": 7,
|
||||
"pages/VacayPage.tsx": 13,
|
||||
"pages/atlas/useAtlas.ts": 2,
|
||||
"pages/collections/collectionsModel.ts": 1,
|
||||
"pages/collections/useCollections.ts": 1,
|
||||
"pages/sharedTrip/SharedChrome.tsx": 2,
|
||||
"pages/sharedTrip/SharedMap.tsx": 1,
|
||||
|
||||
@@ -40,13 +40,14 @@
|
||||
"components/Settings/AppearanceSettingsTab.tsx": 1,
|
||||
"components/Settings/PasskeysSection.tsx": 1,
|
||||
"components/Studio/BookSheetsView.tsx": 1,
|
||||
"components/Studio/SpreadView.tsx": 2,
|
||||
"components/Studio/PhotoView.tsx": 1,
|
||||
"components/Studio/SpreadView.tsx": 1,
|
||||
"components/Studio/StudioElementsPanel.tsx": 2,
|
||||
"components/Studio/StudioSwatches.tsx": 1,
|
||||
"components/Studio/TravelElements.tsx": 6,
|
||||
"components/Studio/autoLayout.ts": 50,
|
||||
"components/Studio/TravelElements.tsx": 3,
|
||||
"components/Studio/peerColour.ts": 1,
|
||||
"components/Studio/printSheets.ts": 4,
|
||||
"components/Studio/travel/badgeLook.ts": 2,
|
||||
"components/Trips/TripMemberAvatar.tsx": 2,
|
||||
"components/Vacay/VacayPersons.tsx": 1,
|
||||
"components/shared/DialogShell.tsx": 2,
|
||||
|
||||
@@ -65,6 +65,9 @@ const InAppNotificationsPage = lazyWithRetry(() => import('./pages/InAppNotifica
|
||||
const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePage'))
|
||||
// The tour keep dialog a day plan row opens; it loads only once a tour is to be kept.
|
||||
const TourSaveToCollectionHost = lazyWithRetry(() => import('./components/Tours/collection/TourSaveToCollectionHost'))
|
||||
// The desktop assistant window: its own chunk, and nothing at all until a connection is set up.
|
||||
const AssistantHost = lazyWithRetry(() => import('./components/Assistant/AssistantHost'))
|
||||
const MAssistantHomeScreen = lazyWithRetry(() => import('./mobile/screens/assistant/MAssistantHomeScreen'))
|
||||
|
||||
// The ten phone screens are chunks of their own, alongside the desktop pages
|
||||
// rather than inside them. Each page used to import its M screen statically and
|
||||
@@ -408,6 +411,12 @@ export default function App() {
|
||||
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
|
||||
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
|
||||
{!hideAuthedWidgets && keepingTour && <ErrorBoundary boundaryId="widget:keep-tour" fallback={null}><Suspense fallback={null}><TourSaveToCollectionHost /></Suspense></ErrorBoundary>}
|
||||
{/* The phone has its own assistant screen inside the trip, and the home chat's outside it. */}
|
||||
{!hideAuthedWidgets && isAuthenticated && (
|
||||
<ErrorBoundary boundaryId="widget:assistant" fallback={null}>
|
||||
<Suspense fallback={null}>{isPhone ? <MAssistantHomeScreen /> : <AssistantHost />}</Suspense>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
|
||||
<ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary>
|
||||
{/* One boundary for all route chunks, above <Routes> so it stays mounted
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* FE-AI-API-001 to FE-AI-API-008
|
||||
*
|
||||
* The two streaming calls bypass axios, so they carry by hand what the axios
|
||||
* instance would add (the cookie, the socket id) and turn a refusal before the
|
||||
* stream into a typed error the chat can name. The refusal's code comes from
|
||||
* the body when the server names one, and from the status otherwise. A
|
||||
* journey's chat reaches the same calls under the journey.
|
||||
*/
|
||||
import type { AssistantChatRequest } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { assistantApi, AssistantRequestError } from './assistant';
|
||||
import { getSocketId } from './websocket';
|
||||
|
||||
const BODY: AssistantChatRequest = { locale: 'en', timeZone: 'Europe/Berlin', threadId: null, text: 'Hi' };
|
||||
|
||||
function streamResponse(text: string): Response {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
let sent = false;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: async () => {
|
||||
if (sent) return { done: true, value: undefined };
|
||||
sent = true;
|
||||
return { done: false, value: bytes };
|
||||
},
|
||||
cancel: async () => {},
|
||||
}),
|
||||
},
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function refusal(status: number, body: unknown): Response {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
body: null,
|
||||
json: async () => {
|
||||
if (body === undefined) throw new SyntaxError('not json');
|
||||
return body;
|
||||
},
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
async function refusedWith(res: Response) {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(res);
|
||||
try {
|
||||
await assistantApi.chat(4, BODY, new AbortController().signal);
|
||||
} catch (err) {
|
||||
return err;
|
||||
}
|
||||
throw new Error('expected a refusal');
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('assistantApi streams', () => {
|
||||
it('FE-AI-API-001: posts the message as JSON with the cookie and the socket id, and yields the chunks', async () => {
|
||||
vi.mocked(getSocketId).mockReturnValueOnce('sock-1');
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(streamResponse('data: {"type":"start","messageId":"m1"}\n\ndata: [DONE]\n\n'));
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
const chunks: unknown[] = [];
|
||||
for await (const chunk of await assistantApi.chat(4, BODY, signal)) chunks.push(chunk);
|
||||
|
||||
expect(chunks).toEqual([{ type: 'start', messageId: 'm1' }]);
|
||||
const [url, init] = fetchSpy.mock.calls[0] ?? [];
|
||||
expect(url).toBe('/api/trips/4/assistant/chat');
|
||||
expect(init).toMatchObject({ method: 'POST', credentials: 'include', signal, body: JSON.stringify(BODY) });
|
||||
expect(init?.headers).toMatchObject({
|
||||
'content-type': 'application/json',
|
||||
accept: 'text/event-stream',
|
||||
'X-Socket-Id': 'sock-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-AI-API-002: sends decisions to the thread they belong to', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(streamResponse('data: [DONE]\n\n'));
|
||||
const body = { locale: 'de', timeZone: 'UTC', decisions: [{ toolCallId: 'c1', approved: true }] };
|
||||
await assistantApi.decide(4, 7, body, new AbortController().signal);
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe('/api/trips/4/assistant/threads/7/decisions');
|
||||
});
|
||||
|
||||
it('FE-AI-API-003: a refusal names the code the body gives', async () => {
|
||||
const err = await refusedWith(refusal(429, { error: 'Daily limit reached', code: 'daily_limit' }));
|
||||
expect(err).toBeInstanceOf(AssistantRequestError);
|
||||
expect(err).toMatchObject({ code: 'daily_limit', status: 429 });
|
||||
});
|
||||
|
||||
it('FE-AI-API-004: without a known code the status decides', async () => {
|
||||
expect(await refusedWith(refusal(429, { error: 'slow down' }))).toMatchObject({ code: 'too_many_requests' });
|
||||
expect(await refusedWith(refusal(409, { error: 'x', code: 'conflict' }))).toMatchObject({ code: 'busy' });
|
||||
expect(await refusedWith(refusal(502, undefined))).toMatchObject({ code: 'provider_unavailable' });
|
||||
expect(await refusedWith(refusal(500, { error: 'boom' }))).toMatchObject({ code: 'generic' });
|
||||
});
|
||||
|
||||
it('FE-AI-API-005: an accepted request without a body is a generic failure, not a silent empty answer', async () => {
|
||||
const err = await refusedWith({ ok: true, status: 200, body: null } as unknown as Response);
|
||||
expect(err).toMatchObject({ code: 'generic', status: 200 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistantApi JSON calls', () => {
|
||||
it('FE-AI-API-006: undo posts an empty JSON body to the escaped tool call id', async () => {
|
||||
let seen: { path: string; body: unknown } | null = null;
|
||||
server.use(
|
||||
http.post('/api/trips/4/assistant/tool-calls/:id/undo', async ({ request, params }) => {
|
||||
seen = { path: String(params.id), body: await request.json() };
|
||||
return HttpResponse.json({ undone: true });
|
||||
})
|
||||
);
|
||||
await expect(assistantApi.undo(4, 'a/b')).resolves.toEqual({ undone: true });
|
||||
expect(seen).toEqual({ path: 'a/b', body: {} });
|
||||
});
|
||||
|
||||
it('FE-AI-API-007: stop posts an empty JSON body', async () => {
|
||||
let body: unknown = null;
|
||||
server.use(
|
||||
http.post('/api/trips/4/assistant/stop', async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
await expect(assistantApi.stop(4)).resolves.toEqual({ success: true });
|
||||
expect(body).toEqual({});
|
||||
});
|
||||
|
||||
it('FE-AI-API-008: a journey scope reaches the journey routes, a trip scope the trip routes', async () => {
|
||||
const journey = { kind: 'journey', journeyId: 12 } as const;
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => streamResponse('data: [DONE]\n\n'));
|
||||
const signal = new AbortController().signal;
|
||||
await assistantApi.chat(journey, BODY, signal);
|
||||
await assistantApi.decide(journey, 7, { locale: 'en', timeZone: 'UTC', decisions: [] }, signal);
|
||||
await assistantApi.chat({ kind: 'trip', tripId: 4 }, BODY, signal);
|
||||
expect(fetchSpy.mock.calls.map(([url]) => url)).toEqual([
|
||||
'/api/journeys/12/assistant/chat',
|
||||
'/api/journeys/12/assistant/threads/7/decisions',
|
||||
'/api/trips/4/assistant/chat',
|
||||
]);
|
||||
|
||||
const hits: string[] = [];
|
||||
server.use(
|
||||
http.get('/api/journeys/12/assistant/threads', () => {
|
||||
hits.push('threads');
|
||||
return HttpResponse.json({ threads: [] });
|
||||
}),
|
||||
http.post('/api/journeys/12/assistant/tool-calls/:id/undo', () => {
|
||||
hits.push('undo');
|
||||
return HttpResponse.json({ undone: true });
|
||||
}),
|
||||
http.post('/api/journeys/12/assistant/stop', () => {
|
||||
hits.push('stop');
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
await assistantApi.threads(journey);
|
||||
await assistantApi.undo(journey, 'c1');
|
||||
await assistantApi.stop(journey);
|
||||
expect(hits).toEqual(['threads', 'undo', 'stop']);
|
||||
});
|
||||
|
||||
it('FE-AI-API-009: the home scope reaches the routes outside any trip', async () => {
|
||||
const home = { kind: 'home' } as const;
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => streamResponse('data: [DONE]\n\n'));
|
||||
const signal = new AbortController().signal;
|
||||
await assistantApi.chat(home, BODY, signal);
|
||||
await assistantApi.decide(home, 7, { locale: 'en', timeZone: 'UTC', decisions: [] }, signal);
|
||||
expect(fetchSpy.mock.calls.map(([url]) => url)).toEqual([
|
||||
'/api/assistant/chat',
|
||||
'/api/assistant/threads/7/decisions',
|
||||
]);
|
||||
|
||||
const hits: string[] = [];
|
||||
server.use(
|
||||
http.get('/api/assistant/threads', () => {
|
||||
hits.push('threads');
|
||||
return HttpResponse.json({ threads: [] });
|
||||
}),
|
||||
http.get('/api/assistant/threads/3', () => {
|
||||
hits.push('thread');
|
||||
return HttpResponse.json({ thread: { id: 3, title: 't', lastMessageAt: 'x' }, messages: [] });
|
||||
}),
|
||||
http.delete('/api/assistant/threads/3', () => {
|
||||
hits.push('delete');
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
http.post('/api/assistant/tool-calls/:id/undo', () => {
|
||||
hits.push('undo');
|
||||
return HttpResponse.json({ undone: true });
|
||||
}),
|
||||
http.post('/api/assistant/stop', () => {
|
||||
hits.push('stop');
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
await assistantApi.threads(home);
|
||||
await assistantApi.thread(home, 3);
|
||||
await assistantApi.deleteThread(home, 3);
|
||||
await assistantApi.undo(home, 'c1');
|
||||
await assistantApi.stop(home);
|
||||
expect(hits).toEqual(['threads', 'thread', 'delete', 'undo', 'stop']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import {
|
||||
ASSISTANT_ERROR_CODES,
|
||||
type AiAdminConnectionResponse,
|
||||
type AiAdminSettings,
|
||||
type AiApiKeyConnectRequest,
|
||||
type AiAuthStartResponse,
|
||||
type AiChatgptFinishRequest,
|
||||
type AiClaudeConnectorResponse,
|
||||
type AiConnectionScope,
|
||||
type AiConnectionView,
|
||||
type AiInstanceConnectionRequest,
|
||||
type AiModelListResponse,
|
||||
type AiOpenrouterFinishRequest,
|
||||
type AiPrivacyAckRequest,
|
||||
type AiStatusResponse,
|
||||
type AssistantAddFoundPlaceRequest,
|
||||
type AssistantAddFoundPlaceResponse,
|
||||
type AssistantChatRequest,
|
||||
type AssistantDecisionsRequest,
|
||||
type AssistantErrorCode,
|
||||
type AssistantInsertFoundPlaceRequest,
|
||||
type AssistantRenderUploadResponse,
|
||||
type AssistantScope,
|
||||
type AssistantShareRequest,
|
||||
type AssistantTakeProposalRequest,
|
||||
type AssistantThreadListResponse,
|
||||
type AssistantThreadResponse,
|
||||
type AssistantUndoResponse,
|
||||
type BookDesignDraftResponse,
|
||||
type CollabMessageResponse,
|
||||
type SuccessResponse,
|
||||
type UiStreamChunk,
|
||||
} from '@trek/shared';
|
||||
import apiClient, { postMultipart } from './client';
|
||||
import { parseUiMessageStream } from './uiMessageStream';
|
||||
import { getSocketId } from './websocket';
|
||||
|
||||
/**
|
||||
* The assistant and the AI connections behind it, in their own file
|
||||
* rather than in the `client.ts` god module.
|
||||
*
|
||||
* The two streaming calls (a new message, and the decisions on held changes)
|
||||
* go through `fetch`, not axios: the answer is a server-sent event stream that
|
||||
* runs for as long as the model works, and axios would buffer it whole behind
|
||||
* its 8 s timeout. Everything else is plain JSON through `apiClient`.
|
||||
*/
|
||||
|
||||
/** A refusal the server answered before the stream opened, named by its `AssistantErrorCode`. */
|
||||
export class AssistantRequestError extends Error {
|
||||
readonly code: AssistantErrorCode;
|
||||
readonly status: number;
|
||||
|
||||
constructor(code: AssistantErrorCode, status: number) {
|
||||
super(`Assistant request refused: ${code}`);
|
||||
this.name = 'AssistantRequestError';
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
const KNOWN_CODES: ReadonlySet<string> = new Set(ASSISTANT_ERROR_CODES);
|
||||
|
||||
/**
|
||||
* The code of a refusal: the body's `code` when it names one, otherwise what
|
||||
* the status alone can say.
|
||||
*/
|
||||
async function refusalCode(res: Response): Promise<AssistantErrorCode> {
|
||||
try {
|
||||
const body: unknown = await res.json();
|
||||
const code = (body as { code?: unknown } | null)?.code;
|
||||
if (typeof code === 'string' && KNOWN_CODES.has(code)) return code as AssistantErrorCode;
|
||||
} catch {
|
||||
// Not JSON (a proxy's error page); the status below still says something.
|
||||
}
|
||||
// A bare 429 is TREK's own limit or a proxy's; a provider's limit always names its code.
|
||||
if (res.status === 429) return 'too_many_requests';
|
||||
if (res.status === 409) return 'busy';
|
||||
if (res.status === 502 || res.status === 503 || res.status === 504) return 'provider_unavailable';
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
async function openStream(path: string, body: unknown, signal: AbortSignal): Promise<AsyncGenerator<UiStreamChunk>> {
|
||||
// A file goes as multipart, where the browser sets the content type with its boundary.
|
||||
const form = body instanceof FormData;
|
||||
const headers: Record<string, string> = form
|
||||
? { accept: 'text/event-stream' }
|
||||
: { 'content-type': 'application/json', accept: 'text/event-stream' };
|
||||
const socketId = getSocketId();
|
||||
if (socketId) headers['X-Socket-Id'] = socketId;
|
||||
const res = await fetch(`/api${path}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers,
|
||||
body: form ? body : JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) throw new AssistantRequestError(await refusalCode(res), res.status);
|
||||
if (!res.body) throw new AssistantRequestError('generic', res.status);
|
||||
return parseUiMessageStream(res.body);
|
||||
}
|
||||
|
||||
const tripBase = (tripId: number) => `/trips/${tripId}/assistant`;
|
||||
|
||||
/**
|
||||
* What a conversation call is about: a trip's id (the planner's chat), or a
|
||||
* scope, which also reaches a journey's chat in TREK Studio and the home chat
|
||||
* outside any one trip. Threads, turns, decisions, undo and stop exist under
|
||||
* all three; files, found places, proposals and sharing belong to a trip and
|
||||
* take its id.
|
||||
*/
|
||||
export type AssistantTarget = number | AssistantScope;
|
||||
|
||||
function scopeBase(target: AssistantTarget): string {
|
||||
if (typeof target === 'number') return tripBase(target);
|
||||
if (target.kind === 'home') return '/assistant';
|
||||
return target.kind === 'trip' ? tripBase(target.tripId) : `/journeys/${target.journeyId}/assistant`;
|
||||
}
|
||||
|
||||
/** Where the Studio uploads a page a running turn of a journey's chat asked for. */
|
||||
const renderPath = (journeyId: number, threadId: number, requestId: string) =>
|
||||
`/journeys/${journeyId}/assistant/threads/${threadId}/renders/${encodeURIComponent(requestId)}`;
|
||||
|
||||
/**
|
||||
* For the calls that make the server go and ask the provider (listing models,
|
||||
* testing a key, exchanging a sign-in code): the shared 8 s is TREK's own
|
||||
* budget and too short for a provider's.
|
||||
*/
|
||||
const viaProvider = { timeout: 30_000 };
|
||||
|
||||
export const assistantApi = {
|
||||
threads: (target: AssistantTarget): Promise<AssistantThreadListResponse> =>
|
||||
apiClient.get(`${scopeBase(target)}/threads`).then((r) => r.data),
|
||||
thread: (target: AssistantTarget, threadId: number): Promise<AssistantThreadResponse> =>
|
||||
apiClient.get(`${scopeBase(target)}/threads/${threadId}`).then((r) => r.data),
|
||||
deleteThread: (target: AssistantTarget, threadId: number): Promise<SuccessResponse> =>
|
||||
apiClient.delete(`${scopeBase(target)}/threads/${threadId}`).then((r) => r.data),
|
||||
undo: (target: AssistantTarget, toolCallId: string): Promise<AssistantUndoResponse> =>
|
||||
apiClient.post(`${scopeBase(target)}/tool-calls/${encodeURIComponent(toolCallId)}/undo`, {}).then((r) => r.data),
|
||||
// Ends this user's running turn, wherever it runs (another tab included).
|
||||
stop: (target: AssistantTarget): Promise<SuccessResponse> =>
|
||||
apiClient.post(`${scopeBase(target)}/stop`, {}).then((r) => r.data),
|
||||
// A place a search found, suggested for a day: answers with the message holding the change.
|
||||
addFoundPlace: (
|
||||
tripId: number,
|
||||
threadId: number,
|
||||
body: AssistantAddFoundPlaceRequest
|
||||
): Promise<AssistantAddFoundPlaceResponse> =>
|
||||
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places`, body).then((r) => r.data),
|
||||
// "+ Tour": a found place taken into the saved tour open in the planner, held as a change like "+ Day 3".
|
||||
insertFoundPlace: (
|
||||
tripId: number,
|
||||
threadId: number,
|
||||
body: AssistantInsertFoundPlaceRequest
|
||||
): Promise<AssistantAddFoundPlaceResponse> =>
|
||||
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places/tour`, body).then((r) => r.data),
|
||||
// A proposal an answer showed as a card ("Plan this"): answers like a found place, with the message holding it.
|
||||
takeProposal: (
|
||||
tripId: number,
|
||||
threadId: number,
|
||||
body: AssistantTakeProposalRequest
|
||||
): Promise<AssistantAddFoundPlaceResponse> =>
|
||||
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/proposals`, body).then((r) => r.data),
|
||||
// An answer posted to the trip chat, marked as shared from the assistant; the client's socket id skips the echo.
|
||||
share: (
|
||||
tripId: number,
|
||||
threadId: number,
|
||||
messageId: string,
|
||||
body: AssistantShareRequest
|
||||
): Promise<CollabMessageResponse> =>
|
||||
apiClient
|
||||
.post(`${tripBase(tripId)}/threads/${threadId}/messages/${encodeURIComponent(messageId)}/share`, body)
|
||||
.then((r) => r.data),
|
||||
|
||||
chat: (target: AssistantTarget, body: AssistantChatRequest, signal: AbortSignal) =>
|
||||
openStream(`${scopeBase(target)}/chat`, body, signal),
|
||||
decide: (target: AssistantTarget, threadId: number, body: AssistantDecisionsRequest, signal: AbortSignal) =>
|
||||
openStream(`${scopeBase(target)}/threads/${threadId}/decisions`, body, signal),
|
||||
// A booking file: the server reads it and proposes each booking it finds as a change.
|
||||
attach: (tripId: number, form: FormData, signal: AbortSignal) =>
|
||||
openStream(`${tripBase(tripId)}/attachments`, form, signal),
|
||||
// A Studio page a running turn asked for, drawn by this tab: the image and what it shows, as multipart.
|
||||
uploadRender: (
|
||||
journeyId: number,
|
||||
threadId: number,
|
||||
requestId: string,
|
||||
form: FormData,
|
||||
signal: AbortSignal
|
||||
): Promise<AssistantRenderUploadResponse> =>
|
||||
postMultipart(renderPath(journeyId, threadId, requestId), form, { signal }),
|
||||
// A whole-book design draft, for a page of it a running turn asked the Studio to draw.
|
||||
bookDesign: (journeyId: number, designId: string): Promise<BookDesignDraftResponse> =>
|
||||
apiClient.get(`/journeys/${journeyId}/book-designs/${encodeURIComponent(designId)}`).then((r) => r.data),
|
||||
};
|
||||
|
||||
/** A user's own connection, and what the assistant runs on for them. */
|
||||
export const aiApi = {
|
||||
status: (): Promise<AiStatusResponse> => apiClient.get('/ai/status').then((r) => r.data),
|
||||
models: (): Promise<AiModelListResponse> => apiClient.get('/ai/models', viaProvider).then((r) => r.data),
|
||||
// Saving tests the key against the provider first.
|
||||
connectKey: (body: AiApiKeyConnectRequest): Promise<AiConnectionView> =>
|
||||
apiClient.put('/ai/connection', body, viaProvider).then((r) => r.data),
|
||||
setModel: (model: string): Promise<AiConnectionView> =>
|
||||
apiClient.patch('/ai/connection/model', { model }).then((r) => r.data),
|
||||
disconnect: (): Promise<SuccessResponse> => apiClient.delete('/ai/connection').then((r) => r.data),
|
||||
ackPrivacy: (body: AiPrivacyAckRequest): Promise<SuccessResponse> =>
|
||||
apiClient.post('/ai/privacy-ack', body).then((r) => r.data),
|
||||
|
||||
chatgptStart: (scope: AiConnectionScope = 'user'): Promise<AiAuthStartResponse> =>
|
||||
apiClient
|
||||
.post(scope === 'instance' ? '/admin/ai/connect/chatgpt/start' : '/ai/connect/chatgpt/start', {})
|
||||
.then((r) => r.data),
|
||||
chatgptFinish: (body: AiChatgptFinishRequest): Promise<AiConnectionView> =>
|
||||
apiClient.post('/ai/connect/chatgpt/finish', body, viaProvider).then((r) => r.data),
|
||||
chatgptSignOut: (): Promise<SuccessResponse> =>
|
||||
apiClient.post('/ai/connect/chatgpt/sign-out', {}).then((r) => r.data),
|
||||
|
||||
openrouterStart: (scope: AiConnectionScope): Promise<AiAuthStartResponse> =>
|
||||
apiClient
|
||||
.post(scope === 'instance' ? '/admin/ai/connect/openrouter/start' : '/ai/connect/openrouter/start', { scope })
|
||||
.then((r) => r.data),
|
||||
openrouterFinish: (body: AiOpenrouterFinishRequest): Promise<AiConnectionView> =>
|
||||
apiClient.post('/ai/connect/openrouter/finish', body, viaProvider).then((r) => r.data),
|
||||
};
|
||||
|
||||
/** The connection the instance provides for everyone, and the assistant's admin settings. */
|
||||
export const aiAdminApi = {
|
||||
settings: (): Promise<AiAdminSettings> => apiClient.get('/admin/ai/settings').then((r) => r.data),
|
||||
saveSettings: (body: AiAdminSettings): Promise<AiAdminSettings> =>
|
||||
apiClient.put('/admin/ai/settings', body).then((r) => r.data),
|
||||
connection: (): Promise<AiAdminConnectionResponse> => apiClient.get('/admin/ai/connection').then((r) => r.data),
|
||||
saveConnection: (body: AiInstanceConnectionRequest): Promise<AiAdminConnectionResponse> =>
|
||||
apiClient.put('/admin/ai/connection', body, viaProvider).then((r) => r.data),
|
||||
removeConnection: (): Promise<SuccessResponse> => apiClient.delete('/admin/ai/connection').then((r) => r.data),
|
||||
models: (): Promise<AiModelListResponse> => apiClient.get('/admin/ai/models', viaProvider).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const claudeConnectorApi = {
|
||||
get: (): Promise<AiClaudeConnectorResponse> => apiClient.get('/integrations/claude-connector').then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { BookDesignSelectionRequest, BookDesignSelectionResponse } from '@trek/shared';
|
||||
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* The assistant's whole-book design drafts as the Studio chat's proposal card
|
||||
* narrows them: the pages of a draft its apply writes. The draft itself is
|
||||
* read through `assistantApi.bookDesign`, which the Studio also draws a page
|
||||
* of a draft from. Online only, like the assistant: a draft lives on the
|
||||
* server for a few hours and nowhere else.
|
||||
*/
|
||||
export const bookDesignApi = {
|
||||
selection: (
|
||||
journeyId: number,
|
||||
designId: string,
|
||||
body: BookDesignSelectionRequest
|
||||
): Promise<BookDesignSelectionResponse> =>
|
||||
apiClient
|
||||
.put(`/journeys/${journeyId}/book-designs/${encodeURIComponent(designId)}/selection`, body)
|
||||
.then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* FE-AI-STREAM-001 to FE-AI-STREAM-011
|
||||
*
|
||||
* The parser reads the server's assistant stream, so its contract is the
|
||||
* shared fixture: the same file the server's writer is tested against. What
|
||||
* the network does to that stream (cutting it at any byte, CRLF from a proxy,
|
||||
* keep-alive comments) must not change what comes out.
|
||||
*/
|
||||
import { uiStreamChunkSchema, type UiStreamChunk } from '@trek/shared';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { MAX_EVENT_CHARS, parseUiMessageStream, STALL_MS } from './uiMessageStream';
|
||||
|
||||
const FIXTURE = readFileSync(
|
||||
resolve(dirname(fileURLToPath(import.meta.url)), '../../../shared/src/assistant/__fixtures__/ui-stream.v1.txt'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
/** A response body that hands out the given pieces one read at a time. */
|
||||
function bodyOf(pieces: (string | Uint8Array)[]) {
|
||||
const encoder = new TextEncoder();
|
||||
let i = 0;
|
||||
const cancel = vi.fn(async () => {});
|
||||
const body = {
|
||||
getReader: () => ({
|
||||
read: async () => {
|
||||
if (i >= pieces.length) return { done: true, value: undefined };
|
||||
const piece = pieces[i++];
|
||||
return { done: false, value: typeof piece === 'string' ? encoder.encode(piece) : piece };
|
||||
},
|
||||
cancel,
|
||||
}),
|
||||
} as unknown as ReadableStream<Uint8Array>;
|
||||
return { body, cancel };
|
||||
}
|
||||
|
||||
async function collect(body: ReadableStream<Uint8Array>): Promise<UiStreamChunk[]> {
|
||||
const chunks: UiStreamChunk[] = [];
|
||||
for await (const chunk of parseUiMessageStream(body)) chunks.push(chunk);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/** The fixture's chunks as plain JSON, the way a reader of the file sees them. */
|
||||
function fixtureChunks(): UiStreamChunk[] {
|
||||
return FIXTURE.split('\n\n')
|
||||
.map((event) => event.replace(/^data: /, '').trim())
|
||||
.filter((data) => data && data !== '[DONE]')
|
||||
.map((data) => uiStreamChunkSchema.parse(JSON.parse(data)));
|
||||
}
|
||||
|
||||
describe('parseUiMessageStream', () => {
|
||||
it('FE-AI-STREAM-001: reads the shared fixture into its chunks, in order', async () => {
|
||||
const chunks = await collect(bodyOf([FIXTURE]).body);
|
||||
expect(chunks).toEqual(fixtureChunks());
|
||||
expect(chunks.map((c) => c.type)).toContain('tool-approval-request');
|
||||
expect(chunks[chunks.length - 1]).toEqual({ type: 'finish', finishReason: 'tool-calls' });
|
||||
});
|
||||
|
||||
it('FE-AI-STREAM-002: gives the same chunks when every byte arrives in its own read', async () => {
|
||||
const bytes = new TextEncoder().encode(FIXTURE);
|
||||
const pieces = Array.from(bytes, (b) => new Uint8Array([b]));
|
||||
expect(await collect(bodyOf(pieces).body)).toEqual(fixtureChunks());
|
||||
});
|
||||
|
||||
it('FE-AI-STREAM-003: reads CRLF line ends, also when a CR and its LF arrive apart', async () => {
|
||||
const crlf = FIXTURE.replace(/\n/g, '\r\n');
|
||||
const cut = crlf.indexOf('\r\n') + 1;
|
||||
expect(await collect(bodyOf([crlf.slice(0, cut), crlf.slice(cut)]).body)).toEqual(fixtureChunks());
|
||||
});
|
||||
|
||||
it('FE-AI-STREAM-004: skips keep-alive comments and fields other than data', async () => {
|
||||
const stream = [
|
||||
': keepalive\n\n',
|
||||
'event: message\nid: 1\ndata: {"type":"start-step"}\n\n',
|
||||
': keepalive\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
].join('');
|
||||
expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'start-step' }]);
|
||||
});
|
||||
|
||||
it('FE-AI-STREAM-005: skips chunk types this client does not know, and torn JSON', async () => {
|
||||
const stream = [
|
||||
'data: {"type":"reasoning-delta","delta":"hm"}\n\n',
|
||||
'data: {"type":"text-delta","id":"t"\n\n',
|
||||
'data: {"type":"text-delta","id":"t","delta":"ok"}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
].join('');
|
||||
expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'text-delta', id: 't', delta: 'ok' }]);
|
||||
});
|
||||
|
||||
it('FE-AI-STREAM-006: reads an unknown error code as generic', async () => {
|
||||
const stream = 'data: {"type":"error","errorText":"quota_exploded"}\n\ndata: [DONE]\n\n';
|
||||
expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'error', errorText: 'generic' }]);
|
||||
});
|
||||
|
||||
it('FE-AI-STREAM-007: joins the lines of a multi-line data field', async () => {
|
||||
const stream = 'data: {"type":"text-delta",\ndata: "id":"t","delta":"x"}\n\ndata: [DONE]\n\n';
|
||||
expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'text-delta', id: 't', delta: 'x' }]);
|
||||
});
|
||||
|
||||
it('FE-AI-STREAM-008: stops at [DONE] and lets go of the connection', async () => {
|
||||
const { body, cancel } = bodyOf([
|
||||
'data: {"type":"start-step"}\n\ndata: [DONE]\n\ndata: {"type":"finish-step"}\n\n',
|
||||
]);
|
||||
expect(await collect(body)).toEqual([{ type: 'start-step' }]);
|
||||
expect(cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-AI-STREAM-009: a stream that closes before [DONE] broke off, after the chunks it finished', async () => {
|
||||
const { body, cancel } = bodyOf(['data: {"type":"start-step"}\n\ndata: {"type":"finish-st']);
|
||||
const seen: UiStreamChunk[] = [];
|
||||
const read = async () => {
|
||||
for await (const chunk of parseUiMessageStream(body)) seen.push(chunk);
|
||||
};
|
||||
await expect(read()).rejects.toThrow('broke off');
|
||||
expect(seen).toEqual([{ type: 'start-step' }]);
|
||||
expect(cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-AI-STREAM-011: a stream that stays silent past the stall limit fails and lets go', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const cancel = vi.fn(async () => {});
|
||||
const body = {
|
||||
getReader: () => ({ read: () => new Promise(() => {}), cancel }),
|
||||
} as unknown as ReadableStream<Uint8Array>;
|
||||
const failed = expect(collect(body)).rejects.toThrow('stalled');
|
||||
await vi.advanceTimersByTimeAsync(STALL_MS);
|
||||
await failed;
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-AI-STREAM-010: refuses an event past the size limit, and a consumer leaving early cancels the read', async () => {
|
||||
const huge = bodyOf([`data: ${'x'.repeat(MAX_EVENT_CHARS + 1)}`]);
|
||||
await expect(collect(huge.body)).rejects.toThrow('too large');
|
||||
expect(huge.cancel).toHaveBeenCalledTimes(1);
|
||||
|
||||
const early = bodyOf([FIXTURE]);
|
||||
for await (const chunk of parseUiMessageStream(early.body)) {
|
||||
expect(chunk.type).toBe('start');
|
||||
break;
|
||||
}
|
||||
expect(early.cancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { UI_STREAM_DONE, uiStreamChunkSchema, type UiStreamChunk } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Reads the assistant's server-sent events into typed chunks.
|
||||
*
|
||||
* The wire format is the `@trek/shared` stream contract: one `data: <json>`
|
||||
* event per chunk, closed by `data: [DONE]`. Our own parser rather than the
|
||||
* `assistant-stream` one, because that one drops `tool-approval-request`, the
|
||||
* chunk a held change hangs on, and it would pull the chat runtime into every
|
||||
* chunk that only needs the data layer.
|
||||
*
|
||||
* What it tolerates, because proxies and the network do these things: events
|
||||
* split across reads at any byte, CRLF line ends, `:` keep-alive comments,
|
||||
* and fields other than `data`. A chunk that does not match the contract,
|
||||
* a type this client does not know among them, is skipped, so the server can
|
||||
* add chunk types without breaking an older tab. An event longer than
|
||||
* MAX_EVENT_CHARS ends the stream with an error instead of growing the buffer
|
||||
* without bound.
|
||||
*
|
||||
* The server always closes with `[DONE]` and sends a keep-alive every 15
|
||||
* seconds, so a stream that ends without the marker, or stays silent for
|
||||
* STALL_MS, lost its server (a restart, a dropped proxy) and fails instead of
|
||||
* leaving the chat waiting for an answer that will not come.
|
||||
*/
|
||||
|
||||
export const MAX_EVENT_CHARS = 1024 * 1024;
|
||||
export const STALL_MS = 45_000;
|
||||
|
||||
/** The next read, or a failure once nothing came for STALL_MS. */
|
||||
async function readOrStall(reader: ReadableStreamDefaultReader<Uint8Array>) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const stalled = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('Assistant stream stalled')), STALL_MS);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([reader.read(), stalled]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function* parseUiMessageStream(body: ReadableStream<Uint8Array>): AsyncGenerator<UiStreamChunk> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let finished = false;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await readOrStall(reader);
|
||||
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
|
||||
// A CR at the very end may be the first half of a CRLF; it waits for the next read.
|
||||
const carry = !done && buffer.endsWith('\r') ? '\r' : '';
|
||||
if (carry) buffer = buffer.slice(0, -1);
|
||||
buffer = buffer.replace(/\r\n?/g, '\n');
|
||||
|
||||
let boundary = buffer.indexOf('\n\n');
|
||||
while (boundary >= 0) {
|
||||
const data = dataOf(buffer.slice(0, boundary));
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
if (data === UI_STREAM_DONE) {
|
||||
finished = true;
|
||||
return;
|
||||
}
|
||||
const chunk = data === null ? null : chunkOf(data);
|
||||
if (chunk) yield chunk;
|
||||
boundary = buffer.indexOf('\n\n');
|
||||
}
|
||||
buffer += carry;
|
||||
|
||||
if (buffer.length > MAX_EVENT_CHARS) throw new Error('Assistant stream event too large');
|
||||
// The connection closed before [DONE]: the server went away mid-answer.
|
||||
if (done) {
|
||||
finished = true;
|
||||
throw new Error('Assistant stream broke off');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Leaving early (the consumer stopped, or an error) releases the connection.
|
||||
if (!finished) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// The stream already failed; the error that brought us here is the one that counts.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The joined `data` lines of one event, or null for an event without any (a comment). */
|
||||
function dataOf(event: string): string | null {
|
||||
const lines: string[] = [];
|
||||
for (const line of event.split('\n')) {
|
||||
if (line === 'data' || line.startsWith('data:')) {
|
||||
const value = line.slice(5);
|
||||
lines.push(value.startsWith(' ') ? value.slice(1) : value);
|
||||
}
|
||||
}
|
||||
return lines.length ? lines.join('\n') : null;
|
||||
}
|
||||
|
||||
function chunkOf(data: string): UiStreamChunk | null {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(data);
|
||||
} catch {
|
||||
// A torn or foreign event; the next one is still worth reading.
|
||||
return null;
|
||||
}
|
||||
const parsed = uiStreamChunkSchema.safeParse(json);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
@@ -43,9 +43,37 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
'roadtripPreferences:changed',
|
||||
// components/Roadtrip/useDayBoundaries reloads the shared boundaries.
|
||||
'roadtripBoundary:changed',
|
||||
// In-app notifications — hooks/useInAppNotificationListener
|
||||
// In-app notifications: hooks/useInAppNotificationListener, which also keeps
|
||||
// the bell's unread count in step (inAppNotificationStore).
|
||||
'notification:new',
|
||||
'notification:updated',
|
||||
'notification:read-all',
|
||||
// The same app-wide listener reads the display settings (settingsStore) and the
|
||||
// tags (tripStore.reloadTags) again; both events carry names, never values.
|
||||
'settings:updated',
|
||||
'tags:changed',
|
||||
// pages/dashboard/useDashboard reads the trip list again (desktop and phone).
|
||||
'trips:changed',
|
||||
// pages/atlas/useAtlas reads the atlas again (desktop and phone).
|
||||
'atlas:changed',
|
||||
// Trip roster: pages/tripPlanner/useTripRosterSync and the desktop packing panel
|
||||
// (components/Packing/usePackingListPanel) read the members again. member:removed
|
||||
// (it may be this user leaving the trip) and member:updated (a guest's new name on
|
||||
// the rows that list people) are applied in the store and read back by the same
|
||||
// hooks; both also move the balances below.
|
||||
'member:added',
|
||||
// Balances: components/Budget/useCostsLedger reads the settlement again on
|
||||
// these and on the budget item events that move a split.
|
||||
'budget:settlement-created',
|
||||
'budget:settlement-updated',
|
||||
'budget:settlement-deleted',
|
||||
// Bags and who looks after a category: components/Packing/useBagTotalsPing and
|
||||
// usePackingCategoryAssignees read them again, for both packing lists.
|
||||
'packing:bag-created',
|
||||
'packing:bag-updated',
|
||||
'packing:bag-deleted',
|
||||
'packing:bag-members-updated',
|
||||
'packing:assignees',
|
||||
// Bag weights (#2191) — content-free ping; components/Packing/usePackingListPanel
|
||||
// and mobile/screens/trip/tabs/MPackingListTab re-read the bags on it. It carries
|
||||
// no state to apply, which is the point: the totals are summed server-side across
|
||||
@@ -72,6 +100,10 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
'vacay:share-removed',
|
||||
'vacay:shared-update',
|
||||
// Journey — pages/journeyDetail/useJourneyDetail ('journey:' prefix listener)
|
||||
// reads the open journey again, and leaves it once journey:deleted says it is gone.
|
||||
// The journey list (pages/journey/useJourney) reads itself again on those two.
|
||||
'journey:updated',
|
||||
'journey:deleted',
|
||||
'journey:trip:synced',
|
||||
'journey:entry:created',
|
||||
'journey:entry:updated',
|
||||
@@ -106,35 +138,32 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
'import:progress',
|
||||
'import:done',
|
||||
'import:error',
|
||||
] as const satisfies readonly TrekWsEventName[]
|
||||
|
||||
/**
|
||||
* Events the client deliberately does not act on today (state of the world
|
||||
* when the registry landed — every one of these was already dropped by the
|
||||
* old silent `default:` branches). Removing an entry means the event is now
|
||||
* handled somewhere; ADDING an entry is a product decision that a new server
|
||||
* event should have no client reaction — never add one just to silence the
|
||||
* registry-parity test.
|
||||
*/
|
||||
export const IGNORED_WS_EVENTS = [
|
||||
'assignment:participants',
|
||||
'packing:reordered',
|
||||
'packing:bag-created',
|
||||
'packing:bag-updated',
|
||||
'packing:bag-deleted',
|
||||
'packing:bag-members-updated',
|
||||
'packing:assignees',
|
||||
'packing:template-applied',
|
||||
'todo:assignees',
|
||||
'budget:settlement-created',
|
||||
'budget:settlement-updated',
|
||||
'budget:settlement-deleted',
|
||||
// Accommodations live in page-local planner state; the client refetches
|
||||
// them off trip:updated date changes, never off these events.
|
||||
// Stays live in page-local planner state, not in the trip store. A stay
|
||||
// written elsewhere (the assistant, a fellow traveller, a plugin, a booking
|
||||
// import) goes remoteEventHandler (STAY_REFRESH_EVENTS) -> accommodations:refresh
|
||||
// -> components/Planner/useStaysRefresh, which reloads them once per burst in
|
||||
// pages/tripPlanner/useTripPlanner (sidebar, map, the phone's day sheet) and
|
||||
// components/Planner/useDayDetail (the desktop day panel).
|
||||
'accommodation:created',
|
||||
'accommodation:updated',
|
||||
'accommodation:deleted',
|
||||
'trip:deleted',
|
||||
'member:added',
|
||||
'member:removed',
|
||||
] as const satisfies readonly TrekWsEventName[]
|
||||
|
||||
/**
|
||||
* Events the client deliberately does not act on, each with the reason. Removing
|
||||
* an entry means the event is now handled somewhere; ADDING one is a product
|
||||
* decision that a server event should have no client reaction, never a way to
|
||||
* silence the registry-parity test, which pins these keys to a list that only
|
||||
* shrinks.
|
||||
*/
|
||||
export const IGNORED_WS_EVENTS: Partial<Record<TrekWsEventName, string>> = {
|
||||
'todo:assignees':
|
||||
'No screen shows who looks after a todo category (todoApi.getCategoryAssignees has no caller), so there is nothing to refresh.',
|
||||
}
|
||||
|
||||
/**
|
||||
* Events registered ahead of the write that sends them. The broadcast parity
|
||||
* work gave every one of them a handler, so the list is empty; it stays as the
|
||||
* place a future event registered before its sender goes, and only shrinks.
|
||||
*/
|
||||
export const AWAITING_SENDER_WS_EVENTS: readonly TrekWsEventName[] = []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -24,23 +24,20 @@ function addonsRoute(addons: ReturnType<typeof buildAddon>[]) {
|
||||
return http.get('/api/admin/addons', () => HttpResponse.json({ addons }));
|
||||
}
|
||||
|
||||
function llmAddon(config: Record<string, unknown> = {}) {
|
||||
function llmAddon(config: Record<string, unknown> = {}, enabled = true) {
|
||||
return buildAddon({
|
||||
id: 'llm_parsing',
|
||||
name: 'AI Parsing',
|
||||
description: 'Extract bookings from files',
|
||||
icon: 'Sparkles',
|
||||
type: 'integration',
|
||||
enabled: true,
|
||||
enabled,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
function modelsRoute(names: string[], seen?: (string | null)[]) {
|
||||
return http.get('/api/admin/llm/local/models', ({ request }) => {
|
||||
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
|
||||
return HttpResponse.json({ models: names.map(name => ({ name, size: 1 })) });
|
||||
});
|
||||
function aiAddon(id: string, name: string, enabled: boolean) {
|
||||
return buildAddon({ id, name, icon: id === 'mcp' ? 'Terminal' : 'Sparkles', type: 'integration', enabled });
|
||||
}
|
||||
|
||||
/** Every switch is labelled with the thing it controls, top-level tile or shelf
|
||||
@@ -576,286 +573,55 @@ describe('AddonManager', () => {
|
||||
expect(links.getAttribute('title')).not.toBe('No shared links yet');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => {
|
||||
server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }]));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('AI Parsing');
|
||||
// The catalog key now exists, so the tile shows the translation rather than falling
|
||||
// back to the English description the server sent.
|
||||
expect(screen.getByText('Reads bookings the built-in parser cannot, using an AI model you choose')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Provider')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-020b: the config lives in the tile shelf, not as a band below the grid', async () => {
|
||||
server.use(addonsRoute([llmAddon({ provider: 'openai' })]));
|
||||
render(<AddonManager />);
|
||||
|
||||
const label = await screen.findByText('Provider');
|
||||
// Inside the AI Parsing tile's <article>, like the collab toggles sit in theirs.
|
||||
const tile = label.closest('article');
|
||||
expect(tile).not.toBeNull();
|
||||
expect(tile!.textContent).toContain('AI Parsing');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
await screen.findByRole('button', { name: 'llama3:8b' });
|
||||
expect(urls[0]).toBe('http://localhost:11434/v1');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
|
||||
|
||||
// qwen3.5:4b is already installed, so the recommended row offers "Use" instead of "Pull"
|
||||
await user.click(screen.getByRole('button', { name: 'Use' }));
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
|
||||
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-022: an unreachable Ollama shows the error and Refresh retries', async () => {
|
||||
const user = userEvent.setup();
|
||||
let calls = 0;
|
||||
it('FE-ADMIN-ADDON-020: the AI addons head the integration column as one tile with a switch each', async () => {
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
calls += 1;
|
||||
return calls === 1
|
||||
? HttpResponse.json({ error: 'down' }, { status: 500 })
|
||||
: HttpResponse.json({ models: [] });
|
||||
}),
|
||||
addonsRoute([
|
||||
aiAddon('mcp', 'MCP', false),
|
||||
llmAddon({}, false),
|
||||
aiAddon('ai_assistant', 'AI Assistant', false),
|
||||
buildAddon({ id: 'airtrail', name: 'AirTrail', type: 'integration', icon: 'Plane' }),
|
||||
]),
|
||||
);
|
||||
render(<AddonManager />);
|
||||
|
||||
// The server's reason is shown, not axios' status line (#2649)
|
||||
await screen.findByText('down');
|
||||
expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Refresh' }));
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
expect(calls).toBe(2);
|
||||
const tile = (await screen.findByText('AI')).closest('article') as HTMLElement;
|
||||
const switches = within(tile).getAllByRole('button').map((b) => b.getAttribute('aria-label'));
|
||||
// In the tile's order, whatever order the server sent them in.
|
||||
expect(switches).toEqual(['AI Assistant', 'AI Parsing', 'MCP']);
|
||||
expect(within(tile).getByText('0/3')).toBeInTheDocument();
|
||||
// Their own icons, keyed by id: the icon column of an install still says Sparkles or Terminal.
|
||||
expect(tile.querySelector('svg.lucide-messages-square')).toBeInTheDocument();
|
||||
expect(tile.querySelector('svg.lucide-scan-text')).toBeInTheDocument();
|
||||
expect(tile.querySelector('svg.lucide-cable')).toBeInTheDocument();
|
||||
// Nothing to set up while neither the assistant nor parsing is on.
|
||||
expect(within(tile).queryByRole('button', { name: 'Set up AI' })).toBeNull();
|
||||
// AirTrail is still its own tile in the same column.
|
||||
expect(screen.getByText('AirTrail').closest('article')).not.toBe(tile);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-023: switching providers swaps the base URL field, the model hint and the Ollama block', async () => {
|
||||
it('FE-ADMIN-ADDON-021: with parsing on, the tile offers the settings dialog, which closes again', async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local', apiKey: '••••••••' })]), modelsRoute([], urls));
|
||||
server.use(
|
||||
addonsRoute([llmAddon({}), aiAddon('ai_assistant', 'AI Assistant', false), aiAddon('mcp', 'MCP', true)]),
|
||||
http.get('/api/admin/ai/settings', () =>
|
||||
HttpResponse.json({ policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } }),
|
||||
),
|
||||
http.get('/api/admin/ai/connection', () => HttpResponse.json({ connection: null, prefill: null })),
|
||||
http.get('/api/ai/status', () => HttpResponse.json({ error: 'not needed' }, { status: 404 })),
|
||||
);
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
|
||||
|
||||
// A hand-typed base URL is used for the next lookup on blur
|
||||
await user.type(screen.getByPlaceholderText('http://localhost:11434/v1'), 'http://ollama.lan:11434/v1');
|
||||
await user.tab();
|
||||
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
||||
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('gpt-4o')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
|
||||
expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument();
|
||||
// A gateway can stand in for Anthropic too, so the field stays (#2655).
|
||||
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
|
||||
expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
|
||||
const tile = (await screen.findByText('AI')).closest('article') as HTMLElement;
|
||||
expect(within(tile).getByText('2/3')).toBeInTheDocument();
|
||||
await user.click(within(tile).getByRole('button', { name: 'Set up AI' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByRole('button', { name: 'AI Parsing' })).toBeInTheDocument();
|
||||
expect(within(dialog).queryByRole('button', { name: 'AI Assistant' })).toBeNull();
|
||||
const closes = within(dialog).getAllByRole('button', { name: 'Close' });
|
||||
await user.click(closes[closes.length - 1]);
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-024: pulling a model streams progress and then selects it', async () => {
|
||||
const user = userEvent.setup();
|
||||
let pulled: unknown = null;
|
||||
let modelCalls = 0;
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
modelCalls += 1;
|
||||
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
|
||||
}),
|
||||
http.post('/api/admin/llm/local/pull', async ({ request }) => {
|
||||
pulled = await request.json();
|
||||
await delay(150);
|
||||
return new HttpResponse(
|
||||
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
|
||||
{ headers: { 'Content-Type': 'application/x-ndjson' } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
|
||||
await screen.findByText('Pulling…');
|
||||
expect(screen.getByText('starting…')).toBeInTheDocument();
|
||||
|
||||
await screen.findByText('Model pulled');
|
||||
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-025: a failing pull surfaces the server error and saving reports both outcomes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true })]),
|
||||
modelsRoute([]),
|
||||
http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error();
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
await screen.findByText('no disk space');
|
||||
expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Saved');
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', vision: 'auto' },
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Failed to save');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-026: model and API key are editable and their hints follow the provider', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
modelsRoute([]),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
expect(screen.getByPlaceholderText('(often not required)')).toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('select or pull below'), ' mistral:7b ');
|
||||
await user.type(screen.getByPlaceholderText('(often not required)'), 'sk-live');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
||||
expect(screen.getByPlaceholderText('sk-…')).toHaveValue('sk-live');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Saved');
|
||||
// The model is trimmed before it is stored, the key is sent verbatim
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'openai', model: 'mistral:7b', baseUrl: '', apiKey: 'sk-live', vision: 'auto' },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-029: switching to Anthropic clears a stale base URL before saving', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local', model: '', baseUrl: 'http://ollama.lan:11434/v1', apiKey: '', multimodal: false })]),
|
||||
modelsRoute([]),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
|
||||
await user.type(screen.getByPlaceholderText('claude-opus-4-8'), 'claude-haiku-4-5-20251001');
|
||||
await user.type(screen.getByPlaceholderText('sk-…'), 'sk-ant-live');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Saved');
|
||||
// The stale local base URL must not ride along to Anthropic — it would hijack the endpoint.
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-038: whether the model reads images is auto, on or off, kept as stored and saved as chosen', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'openai', model: 'gpt-4.1-mini', baseUrl: '', apiKey: 'sk', vision: 'off' })]),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
// Stored off stays off; a cloud provider is told what Automatic means for it.
|
||||
await user.click(await screen.findByRole('button', { name: 'No' }));
|
||||
expect(screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Yes' }));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Saved');
|
||||
expect(bodies[0]).toMatchObject({ config: { provider: 'openai', vision: 'on' } });
|
||||
expect(bodies[0]).not.toHaveProperty('config.multimodal');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-039: with the local provider the hint says Automatic asks the server', async () => {
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local', model: 'qwen3.5:4b' })]), modelsRoute([]));
|
||||
render(<AddonManager />);
|
||||
expect(await screen.findByText('Automatic asks the Ollama server whether this model reads images.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-027: an error frame in the pull stream aborts the pull and is reported', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
modelsRoute([]),
|
||||
http.post('/api/admin/llm/local/pull', () => new HttpResponse(
|
||||
'{"status":"pulling manifest"}\n{"error":"manifest not found"}\n',
|
||||
{ headers: { 'Content-Type': 'application/x-ndjson' } },
|
||||
)),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
|
||||
await screen.findByText('manifest not found');
|
||||
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
|
||||
expect(screen.queryByText('Pulling…')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-028: blurring the base URL under a cloud provider queries no local models', async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(addonsRoute([llmAddon({ provider: 'openai' })]), modelsRoute([], urls));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Provider');
|
||||
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('https://api.openai.com/v1'), 'https://proxy.local/v1');
|
||||
await user.tab();
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument());
|
||||
expect(urls).toHaveLength(0);
|
||||
});
|
||||
it('FE-ADMIN-ADDON-040: a shelf of several options starts folded and says how many there are and how many are on', async () => {
|
||||
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
|
||||
render(
|
||||
@@ -901,6 +667,10 @@ describe('AddonManager', () => {
|
||||
</>
|
||||
);
|
||||
|
||||
// Folded like Collab's and Documents' options until the bar is opened.
|
||||
await screen.findByRole('button', { name: /\d+ options?/, expanded: false });
|
||||
expect(screen.queryByRole('button', { name: 'wanderer' })).not.toBeInTheDocument();
|
||||
await unfoldShelves();
|
||||
const toggle = await screen.findByRole('button', { name: 'wanderer' });
|
||||
expect(isOn(toggle)).toBe(false);
|
||||
await userEvent.click(toggle);
|
||||
@@ -921,6 +691,7 @@ describe('AddonManager', () => {
|
||||
</>
|
||||
);
|
||||
|
||||
await unfoldShelves();
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'wanderer' }));
|
||||
|
||||
await waitFor(() => expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(false));
|
||||
@@ -941,4 +712,33 @@ describe('AddonManager', () => {
|
||||
expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument();
|
||||
expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-045: finds an addon by name or description and shows the ones on or off', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
addonsRoute([
|
||||
buildAddon({ id: 'x-lists', name: 'Todo List', description: 'Track tasks', type: 'trip', enabled: true }),
|
||||
buildAddon({ id: 'x-costs', name: 'Budget', description: 'Split costs', type: 'trip', enabled: false }),
|
||||
buildAddon({ id: 'x-holiday', name: 'Vacay', description: 'Holiday planner', type: 'global', enabled: true }),
|
||||
])
|
||||
);
|
||||
render(<AddonManager />);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'costs');
|
||||
expect(screen.getByText('Budget')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Todo List')).toBeNull();
|
||||
expect(screen.queryByText('Vacay')).toBeNull();
|
||||
|
||||
await user.clear(screen.getByRole('searchbox', { name: 'Search addons' }));
|
||||
await user.click(screen.getByRole('button', { name: 'On' }));
|
||||
expect(screen.getByText('Todo List')).toBeInTheDocument();
|
||||
expect(screen.getByText('Vacay')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Budget')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Off' }));
|
||||
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'nothing like this');
|
||||
expect(screen.getByText('No addon matches the search or the filter.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useId, type ComponentType } from 'react'
|
||||
import { useState, type ComponentType } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw, Mountain, CarFront } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront, Search } from 'lucide-react'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import AirTrailIcon from '../shared/AirTrailIcon'
|
||||
@@ -12,11 +10,10 @@ import AddonTile from './AddonTile'
|
||||
import AddonSubRow from './AddonSubRow'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
||||
import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit'
|
||||
import { type Addon, type CollabFeatures, ANTHROPIC_HINT, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel'
|
||||
import { SettingsCard, StatusPill } from '../Settings/settingsKit'
|
||||
import { type Addon, type CollabFeatures, COLLAB_SUB_FEATURES, getAddonLabel } from './addons/addonModel'
|
||||
import { useAddonManager } from './addons/useAddonManager'
|
||||
import { useLlmParsingConfig } from './addons/useLlmParsingConfig'
|
||||
import AiAddonTile from './addons/AiAddonTile'
|
||||
import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons'
|
||||
import WandererMark from '../Tours/wanderer/WandererMark'
|
||||
|
||||
@@ -74,11 +71,13 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
const { t } = useTranslation()
|
||||
const dark = useIsDark()
|
||||
const {
|
||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
|
||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
|
||||
toursWanderer,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
} = useAddonManager()
|
||||
const [query, setQuery] = useState('')
|
||||
const [view, setView] = useState<AddonView>('all')
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -154,33 +153,29 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
)
|
||||
})
|
||||
}
|
||||
// An array, not a fragment: the tile counts its rows to fold them behind the
|
||||
// "N options" bar the way Collab's and Documents' are.
|
||||
if (addon.id === 'tours' && addon.enabled) {
|
||||
return (
|
||||
<>
|
||||
return [
|
||||
<AddonSubRow
|
||||
key="wanderer"
|
||||
icon={<WandererMark size={14} />}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
description={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
/>,
|
||||
...mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
|
||||
<AddonSubRow
|
||||
icon={<WandererMark size={14} />}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
description={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
key={key}
|
||||
icon={<Icon size={14} />}
|
||||
title={t(titleKey)}
|
||||
description={t(subtitleKey)}
|
||||
enabled={enabled}
|
||||
onToggle={() => void toggle()}
|
||||
/>
|
||||
{mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
|
||||
<AddonSubRow
|
||||
key={key}
|
||||
icon={<Icon size={14} />}
|
||||
title={t(titleKey)}
|
||||
description={t(subtitleKey)}
|
||||
enabled={enabled}
|
||||
onToggle={() => void toggle()}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
// The AI-parsing settings hang off their tile like every other sub-feature,
|
||||
// just as a form instead of toggle rows.
|
||||
if (addon.id === 'llm_parsing' && addon.enabled) {
|
||||
return <LlmParsingConfig addon={addon} />
|
||||
)),
|
||||
]
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -201,17 +196,17 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
)
|
||||
}
|
||||
|
||||
/* One column per type, side by side, instead of three stacked sections. Stacked, each
|
||||
section opened its own set of columns and the tall tiles (Collab, Journey) set a
|
||||
section's height while its neighbours ran out early. Side by side the column's
|
||||
head carries the type, so the tiles need no badge of their own. */
|
||||
/* A card per type, the way every other admin tab sets its cards: Trip in the
|
||||
left column, Global and Integration in the right one, each card with the
|
||||
dialogs' head band carrying the type, its count and what it means. Above
|
||||
them one card says how many are on and finds an addon by name. */
|
||||
const groups = [
|
||||
{ key: 'trip', addons: tripAddons },
|
||||
{ key: 'global', addons: globalAddons },
|
||||
{ key: 'integration', addons: integrationAddons },
|
||||
].filter(g => g.addons.length > 0)
|
||||
].filter(g => g.addons.length > 0 || (g.key === 'integration' && aiAddons.length > 0))
|
||||
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length
|
||||
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
|
||||
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length + aiAddons.length
|
||||
|
||||
const subtitle = (
|
||||
<span className="inline-flex flex-wrap items-center gap-1">
|
||||
@@ -221,201 +216,145 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
icon={Puzzle}
|
||||
title={t('admin.addons.title')}
|
||||
hint={subtitle}
|
||||
badge={totalCount > 0 ? (
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
>
|
||||
{addons.length === 0 ? (
|
||||
<EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{groups.map(g => {
|
||||
const meta = TYPE_META[g.key]
|
||||
return (
|
||||
<section key={g.key} className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
|
||||
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.addons} t={t} />
|
||||
<div className="divide-y divide-edge-faint">{g.addons.map(tile)}</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
/** Whether an addon is shown under the search and the filter. */
|
||||
const shown = (addon: Addon) => {
|
||||
if (view === 'on' && !addon.enabled) return false
|
||||
if (view === 'off' && addon.enabled) return false
|
||||
const q = query.trim().toLocaleLowerCase()
|
||||
if (!q) return true
|
||||
const label = getAddonLabel(t, addon)
|
||||
return `${label.name} ${label.description ?? ''}`.toLocaleLowerCase().includes(q)
|
||||
}
|
||||
|
||||
const card = (g: (typeof groups)[number]) => {
|
||||
const meta = TYPE_META[g.key]
|
||||
const all = g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons
|
||||
const visible = g.addons.filter(shown)
|
||||
const ai = g.key === 'integration' && aiAddons.some(shown)
|
||||
if (!visible.length && !ai) return null
|
||||
const on = all.filter(a => a.enabled).length
|
||||
return (
|
||||
<SettingsCard
|
||||
key={g.key}
|
||||
icon={meta.icon}
|
||||
title={t(meta.labelKey)}
|
||||
hint={t(meta.hintKey)}
|
||||
badge={(
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled: on, total: all.length })}>
|
||||
<span className="inline-flex"><StatusPill tone={on > 0 ? 'success' : 'neutral'}>{on}/{all.length}</StatusPill></span>
|
||||
</Tooltip>
|
||||
)}
|
||||
bodyClassName="p-3"
|
||||
>
|
||||
<div className="divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
|
||||
{/* The AI addons share a connection and a settings dialog, so they head the card as one tile. */}
|
||||
{ai && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
|
||||
{visible.map(tile)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
const [first, ...rest] = groups
|
||||
const cards = { start: first ? card(first) : null, end: rest.map(card).filter(Boolean) }
|
||||
|
||||
/** The head band of one type's column: icon tile, the type, its count and what it means. */
|
||||
function GroupHead({ icon: Icon, label, hint, addons, t }: {
|
||||
icon: ComponentType<{ size?: number; className?: string; strokeWidth?: number }>
|
||||
label: string
|
||||
hint: string
|
||||
addons: Addon[]
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
}) {
|
||||
const enabled = addons.filter(a => a.enabled).length
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 border-b border-edge-faint bg-surface-tertiary px-3.5 py-2.5">
|
||||
<span className="mt-px grid h-7 w-7 flex-none place-items-center rounded-[9px] bg-surface-card text-content-secondary shadow-sm">
|
||||
<Icon size={13} strokeWidth={2} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="m-0 min-w-0 flex-1 truncate font-bold text-content" style={fs(13, 'body')}>{label}</h3>
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled, total: addons.length })}>
|
||||
<>
|
||||
<SettingsCard
|
||||
icon={Puzzle}
|
||||
title={t('admin.addons.title')}
|
||||
hint={subtitle}
|
||||
badge={totalCount > 0 ? (
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill>{enabled}/{addons.length}</StatusPill>
|
||||
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
>
|
||||
{addons.length === 0
|
||||
? <EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
|
||||
: <AddonToolbar groups={groups.map(g => ({ key: g.key, addons: g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons }))} query={query} onQuery={setQuery} view={view} onView={setView} t={t} />}
|
||||
</SettingsCard>
|
||||
|
||||
{addons.length > 0 && (cards.start || cards.end.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-2 xl:items-start">
|
||||
<div className="min-w-0">{cards.start}</div>
|
||||
<div className="min-w-0">{cards.end}</div>
|
||||
</div>
|
||||
{/* Says what the type means: the only place that still explains it. */}
|
||||
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11)}>{hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="m-0 rounded-2xl border border-dashed border-edge-faint px-4 py-8 text-center text-content-muted" style={fs(13, 'body')}>
|
||||
{t('admin.addons.noMatch')}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
||||
const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py-1 font-medium transition-colors disabled:cursor-default disabled:opacity-60'
|
||||
type AddonView = 'all' | 'on' | 'off'
|
||||
|
||||
/**
|
||||
* Instance-wide AI-parsing config. When set, applies to the whole instance and
|
||||
* overrides per-user config (see server llmConfig.ts). The API key is masked on
|
||||
* read; an unchanged mask is treated as a no-op by the server. For the local
|
||||
* provider, it also lists installed Ollama models and can pull NuExtract models.
|
||||
* The head card's body: a tile per type with how many of it are on, then a
|
||||
* search over the addons' names and descriptions and a switch between all,
|
||||
* the ones on and the ones off.
|
||||
*/
|
||||
function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
provider, setProvider, model, setModel, baseUrl, setBaseUrl, baseUrlPlaceholder, apiKey, setApiKey, vision, setVision, saving,
|
||||
installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull, save,
|
||||
} = useLlmParsingConfig(addon)
|
||||
const fieldId = useId()
|
||||
|
||||
const providerOptions = [
|
||||
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
|
||||
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
|
||||
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
|
||||
]
|
||||
const visionOptions = LLM_VISION_MODES.map(value => ({ value, label: t(`admin.addons.llm.vision.${value}`) }))
|
||||
|
||||
/* Lives in the tile's shelf like the collab toggles, so it is one column of
|
||||
eyebrow fields: the band this used to be had a whole page width. */
|
||||
function AddonToolbar({ groups, query, onQuery, view, onView, t }: {
|
||||
groups: { key: string; addons: Addon[] }[]
|
||||
query: string
|
||||
onQuery: (q: string) => void
|
||||
view: AddonView
|
||||
onView: (v: AddonView) => void
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
}) {
|
||||
return (
|
||||
<li className={`flex flex-col gap-3 p-3 ${saving ? 'opacity-60' : ''}`}>
|
||||
<SettingsHint>
|
||||
Instance-wide — applies to all users. Leave blank to let each user configure their own provider.
|
||||
</SettingsHint>
|
||||
|
||||
<EditorField label="Provider">
|
||||
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
|
||||
</EditorField>
|
||||
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
|
||||
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={baseUrlPlaceholder} />
|
||||
</EditorField>
|
||||
<EditorField
|
||||
label="API key"
|
||||
htmlFor={`${fieldId}-key`}
|
||||
hint={provider === 'anthropic' ? ANTHROPIC_HINT : undefined}
|
||||
>
|
||||
<input id={`${fieldId}-key`} type="password" className={INPUT} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
|
||||
</EditorField>
|
||||
<EditorField label="Model" htmlFor={`${fieldId}-model`}>
|
||||
<input id={`${fieldId}-model`} autoComplete="off" className={`${INPUT} font-geist`} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
|
||||
</EditorField>
|
||||
|
||||
<EditorField label={t('settings.aiParsing.multimodal')} hint={t(provider === 'local' ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}>
|
||||
<Segmented<LlmVision>
|
||||
label={t('settings.aiParsing.multimodal')}
|
||||
value={vision}
|
||||
onChange={v => setVision(asLlmVision(v))}
|
||||
options={visionOptions}
|
||||
fill
|
||||
/>
|
||||
</EditorField>
|
||||
|
||||
{/* Local model management (Ollama) */}
|
||||
{provider === 'local' && (
|
||||
<div className="flex flex-col gap-2.5 rounded-[12px] border border-edge-faint bg-surface-card p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`${EYEBROW} min-w-0 flex-1 truncate`} style={fs(9.5)}>Installed on the server</span>
|
||||
<button type="button" onClick={loadModels} disabled={loadingModels}
|
||||
className="inline-flex flex-none items-center gap-1 rounded-full px-2 py-0.5 font-medium text-content-muted hover:bg-surface-secondary hover:text-content disabled:cursor-default disabled:opacity-60"
|
||||
style={fs(11.5, 'body')}>
|
||||
<RefreshCw size={11} strokeWidth={2.2} className={loadingModels ? 'animate-spin' : undefined} />
|
||||
{loadingModels ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
{modelsErr && <p className="m-0 break-words text-danger" style={fs(11.5)}>{modelsErr}</p>}
|
||||
{!modelsErr && installed.length === 0 && !loadingModels && (
|
||||
<SettingsHint>No models installed yet — pull one below.</SettingsHint>
|
||||
)}
|
||||
{installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{installed.map(name => (
|
||||
<button type="button"
|
||||
key={name}
|
||||
title={name}
|
||||
onClick={() => setModel(name)}
|
||||
aria-pressed={model === name}
|
||||
className={`max-w-full truncate rounded-full px-2.5 py-[3px] font-geist font-medium transition-colors ${model === name ? 'bg-accent text-accent-text' : 'border border-edge bg-surface-card text-content-muted hover:text-content'}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{groups.map(g => {
|
||||
const meta = TYPE_META[g.key]
|
||||
const Icon = meta.icon
|
||||
const on = g.addons.filter(a => a.enabled).length
|
||||
return (
|
||||
<div key={g.key} className="flex items-center gap-3 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5">
|
||||
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-secondary text-content-secondary">
|
||||
<Icon size={15} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-content" style={fs(12.5, 'body')}>{t(meta.labelKey)}</span>
|
||||
<span className="block text-content-faint" style={fs(11)}>{t('admin.addons.group.count', { enabled: on, total: g.addons.length })}</span>
|
||||
</span>
|
||||
<span className={`font-geist font-bold tabular-nums ${on > 0 ? 'text-content' : 'text-content-faint'}`} style={fs(18, 'body')} aria-hidden>{on}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-edge-faint pt-2.5">
|
||||
<span className={EYEBROW} style={fs(9.5)}>Pull a recommended model</span>
|
||||
{RECOMMENDED_MODELS.map(m => {
|
||||
const installedHere = isInstalled(m.id)
|
||||
const isPulling = pulling === m.id
|
||||
const active = model === m.id
|
||||
return (
|
||||
<div key={m.id} className="min-w-0" title={m.note}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>{m.label}</span>
|
||||
{m.recommended && <StatusPill tone="success">Recommended</StatusPill>}
|
||||
{installedHere ? (
|
||||
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`${SMALL_BUTTON} ${active ? 'bg-surface-tertiary text-content-muted' : 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary'}`} style={fs(12, 'body')}>
|
||||
{active ? 'Selected' : 'Use'}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className={`${SMALL_BUTTON} bg-accent text-accent-text hover:opacity-90`} style={fs(12, 'body')}>
|
||||
{isPulling ? 'Pulling…' : 'Pull'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isPulling && (
|
||||
<div className="mt-2">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div className="h-full rounded-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
|
||||
</div>
|
||||
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={save} disabled={saving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<label className="relative flex min-w-0 flex-1 items-center">
|
||||
<Search size={14} className="pointer-events-none absolute start-3 text-content-faint" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={e => onQuery(e.target.value)}
|
||||
placeholder={t('admin.addons.search')}
|
||||
aria-label={t('admin.addons.search')}
|
||||
className="h-9 w-full rounded-[10px] border border-edge-faint bg-surface-card ps-8 pe-3 text-content outline-none transition-colors placeholder:text-content-faint focus:border-accent"
|
||||
style={fs(13, 'body')}
|
||||
/>
|
||||
</label>
|
||||
<div className="inline-flex flex-none gap-1 rounded-[10px] bg-surface-tertiary p-1" role="group" aria-label={t('admin.addons.filter')}>
|
||||
{(['all', 'on', 'off'] as const).map(v => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => onView(v)}
|
||||
aria-pressed={view === v}
|
||||
className={`h-7 rounded-[8px] px-3 font-medium transition-colors ${view === v ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t(`admin.addons.filter.${v}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ChevronRight, Settings2, Sparkles } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { StatusPill } from '../../Settings/settingsKit';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import AddonSubRow from '../AddonSubRow';
|
||||
import { type Addon, getAddonLabel } from './addonModel';
|
||||
import { AI_ADDON_ICONS, type AiAddonId } from './aiAddons';
|
||||
import AiSettingsDialog from './AiAdminConfig';
|
||||
import { useAiAddonTile } from './useAiAddonTile';
|
||||
|
||||
/**
|
||||
* The AI addons as one tile at the head of the integration column: the
|
||||
* assistant, AI parsing and MCP as switches in its shelf, and one button to
|
||||
* the dialog that holds their settings, so a page with all three on stays as
|
||||
* short as one with none.
|
||||
*/
|
||||
export default function AiAddonTile({
|
||||
addons,
|
||||
onToggle,
|
||||
}: {
|
||||
addons: Addon[];
|
||||
onToggle: (addon: Addon) => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const tile = useAiAddonTile(addons);
|
||||
const anyOn = tile.enabledCount > 0;
|
||||
return (
|
||||
<article className="px-3.5 py-3 transition-colors duration-150 focus-within:bg-surface-secondary">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`grid h-9 w-9 flex-none place-items-center rounded-[10px] transition-colors ${
|
||||
anyOn
|
||||
? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint'
|
||||
: 'bg-surface-tertiary text-content-faint'
|
||||
}`}
|
||||
>
|
||||
<Sparkles size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-px">
|
||||
<h4 className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||
{t('admin.addons.ai.name')}
|
||||
</h4>
|
||||
<p
|
||||
className={`m-0 mt-0.5 line-clamp-2 leading-snug ${anyOn ? 'text-content-muted' : 'text-content-faint'}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{t('admin.addons.ai.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-none pt-1">
|
||||
<StatusPill tone={anyOn ? 'success' : 'neutral'}>
|
||||
{tile.enabledCount}/{addons.length}
|
||||
</StatusPill>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
|
||||
<ul className="m-0 list-none divide-y divide-edge-faint p-0">
|
||||
{addons.map((addon) => {
|
||||
const Icon = AI_ADDON_ICONS[addon.id as AiAddonId];
|
||||
const label = getAddonLabel(t, addon);
|
||||
return (
|
||||
<AddonSubRow
|
||||
key={addon.id}
|
||||
icon={<Icon size={14} />}
|
||||
title={label.name}
|
||||
description={label.description}
|
||||
enabled={addon.enabled}
|
||||
onToggle={() => onToggle(addon)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{tile.canSetUp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={tile.openDialog}
|
||||
className="flex min-h-[44px] w-full items-center gap-2.5 border-t border-edge-faint px-3 py-1.5 text-start transition-colors hover:bg-surface-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent"
|
||||
>
|
||||
<span className="grid h-6 w-6 flex-none place-items-center text-content-muted">
|
||||
<Settings2 size={14} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
|
||||
{t('admin.addons.ai.setup')}
|
||||
</span>
|
||||
<ChevronRight size={15} strokeWidth={2.2} className="flex-none text-content-faint rtl:rotate-180" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tile.dialogOpen && <AiSettingsDialog addons={addons} onClose={tile.closeDialog} />}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
// FE-COMP-AI-ADMIN-001 to FE-COMP-AI-ADMIN-020: the AI settings dialog behind the AI tile.
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { server } from '../../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { ToastContainer } from '../../shared/Toast';
|
||||
import type { Addon } from './addonModel';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
status: vi.fn(),
|
||||
adminSettings: vi.fn(),
|
||||
saveAdminSettings: vi.fn(),
|
||||
adminConnection: vi.fn(),
|
||||
saveAdminConnection: vi.fn(),
|
||||
removeAdminConnection: vi.fn(),
|
||||
adminModels: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
|
||||
|
||||
const { default: AiSettingsDialog } = await import('./AiAdminConfig');
|
||||
|
||||
const SETTINGS: AiAdminSettings = {
|
||||
policy: 'instance_or_user',
|
||||
dailyTurns: 50,
|
||||
webSearch: true,
|
||||
retention: { mode: 'days', days: 90 },
|
||||
};
|
||||
const LIVE: AiAdminConnectionResponse = {
|
||||
connection: {
|
||||
scope: 'instance',
|
||||
kind: 'openai',
|
||||
model: 'gpt-5.5',
|
||||
hint: '…9f3c',
|
||||
status: 'ok',
|
||||
host: 'api.openai.com',
|
||||
baseUrl: null,
|
||||
},
|
||||
prefill: null,
|
||||
};
|
||||
|
||||
function addon(id: string, enabled: boolean, config?: Record<string, unknown>): Addon {
|
||||
return { id, name: id, description: '', icon: 'Sparkles', type: 'integration', enabled, config } as Addon;
|
||||
}
|
||||
|
||||
const ASSISTANT = addon('ai_assistant', true);
|
||||
const parsing = (config: Record<string, unknown> = {}) => addon('llm_parsing', true, config);
|
||||
|
||||
function renderDialog(addons: Addon[] = [ASSISTANT]) {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AiSettingsDialog addons={addons} onClose={onClose} />
|
||||
</>
|
||||
);
|
||||
return { onClose };
|
||||
}
|
||||
|
||||
/** The dialog opened on AI parsing's tab. */
|
||||
async function renderParsing(config: Record<string, unknown> = {}) {
|
||||
const user = userEvent.setup();
|
||||
renderDialog([ASSISTANT, parsing(config)]);
|
||||
await user.click(await screen.findByRole('button', { name: 'AI Parsing' }));
|
||||
return user;
|
||||
}
|
||||
|
||||
function modelsRoute(names: string[], seen?: (string | null)[]) {
|
||||
return http.get('/api/admin/llm/local/models', ({ request }) => {
|
||||
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
|
||||
return HttpResponse.json({ models: names.map((name) => ({ name, size: 1 })) });
|
||||
});
|
||||
}
|
||||
|
||||
function saveRoute(bodies: unknown[], fail = false) {
|
||||
return http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return fail ? HttpResponse.error() : HttpResponse.json({ success: true });
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
repo.status.mockRejectedValue(new Error('not needed'));
|
||||
repo.adminSettings.mockResolvedValue(SETTINGS);
|
||||
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
|
||||
repo.adminConnection.mockResolvedValue({ connection: null, prefill: null });
|
||||
repo.removeAdminConnection.mockResolvedValue(undefined);
|
||||
repo.adminModels.mockResolvedValue([{ id: 'gpt-5.5', label: 'GPT-5.5', supportsTools: true }]);
|
||||
});
|
||||
|
||||
describe('the connection and the assistant', () => {
|
||||
it('FE-COMP-AI-ADMIN-001: shows who pays and the setup for everyone, the limits on the assistant tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByText('Everyone uses the connection below unless they connect their own.')).toBeInTheDocument();
|
||||
expect(screen.getByText('No connection yet.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Own server/ })).toBeInTheDocument();
|
||||
// The admin may share their own ChatGPT plan with everyone, so its tile is offered too.
|
||||
expect(screen.getByRole('button', { name: /Sign in with ChatGPT/ })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'AI Assistant' }));
|
||||
expect(screen.getByDisplayValue('50')).toBeInTheDocument();
|
||||
expect(screen.getByText('Keep chats')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-001b: the web search is on as it ships and is switched off on the assistant tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
await user.click(await screen.findByRole('button', { name: 'AI Assistant' }));
|
||||
const toggle = screen.getByRole('button', { name: 'Web search' });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByText(/through the search of the AI provider it talks to/)).toBeInTheDocument();
|
||||
await user.click(toggle);
|
||||
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, webSearch: false });
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-002: "Everyone uses their own" drops the connection and the limit', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
await user.click(await screen.findByRole('radio', { name: 'Everyone uses their own account' }));
|
||||
await waitFor(() => expect(screen.queryByText('No connection yet.')).not.toBeInTheDocument());
|
||||
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, policy: 'user' });
|
||||
expect(screen.getByText('Everyone connects their own provider. Nothing is charged to you.')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'AI Assistant' }));
|
||||
expect(screen.queryByText('Messages per person per day')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-003: a live connection shows its provider and opens the setup only to change it', async () => {
|
||||
const user = userEvent.setup();
|
||||
repo.adminConnection.mockResolvedValue(LIVE);
|
||||
renderDialog();
|
||||
expect(await screen.findByText('OpenAI')).toBeInTheDocument();
|
||||
expect(screen.getByText('…9f3c')).toBeInTheDocument();
|
||||
expect(screen.queryByText('What would you like to chat with?')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Change' }));
|
||||
expect(screen.getByText('What would you like to chat with?')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(screen.getByText('OpenAI')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-004: removing the connection asks first', async () => {
|
||||
const user = userEvent.setup();
|
||||
repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue({ connection: null, prefill: null });
|
||||
renderDialog();
|
||||
await user.click(await screen.findByRole('button', { name: 'Remove connection' }));
|
||||
expect(screen.getByText('Disconnect OpenAI? Your chats stay.')).toBeInTheDocument();
|
||||
const buttons = screen.getAllByRole('button', { name: 'Remove connection' });
|
||||
await user.click(buttons[buttons.length - 1]);
|
||||
expect(await screen.findByText('No connection yet.')).toBeInTheDocument();
|
||||
expect(repo.removeAdminConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-005: a tab shows only while its addon is on, and Close closes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onClose } = renderDialog([addon('ai_assistant', false), parsing()]);
|
||||
expect(await screen.findByRole('button', { name: 'AI Parsing' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'AI Assistant' })).not.toBeInTheDocument();
|
||||
const closes = screen.getAllByRole('button', { name: 'Close' });
|
||||
await user.click(closes[closes.length - 1]);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI parsing', () => {
|
||||
it('FE-COMP-AI-ADMIN-010: without its own connection parsing reads with the shared one, and can be given its own', async () => {
|
||||
repo.adminConnection.mockResolvedValue(LIVE);
|
||||
server.use(modelsRoute([]));
|
||||
const user = await renderParsing({ vision: 'auto' });
|
||||
expect(await screen.findByText('Shared')).toBeInTheDocument();
|
||||
expect(screen.getByText('gpt-5.5')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Model')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Use a different one' }));
|
||||
expect(screen.getByLabelText('Model')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(screen.queryByLabelText('Model')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-011: with no connection anywhere it says so and leads to the connection tab', async () => {
|
||||
const user = await renderParsing();
|
||||
expect(
|
||||
await screen.findByText('No connection yet. Set one up for everyone, or give AI parsing its own.')
|
||||
).toBeInTheDocument();
|
||||
// The link in the hint, not the tab of the same name above it.
|
||||
const link = screen.getAllByRole('button', { name: 'Connection' }).find((b) => !b.hasAttribute('aria-pressed'));
|
||||
await user.click(link as HTMLElement);
|
||||
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-012: an own connection is shown as such and goes back to the shared one, keeping the vision setting', async () => {
|
||||
const bodies: unknown[] = [];
|
||||
repo.adminConnection.mockResolvedValue(LIVE);
|
||||
server.use(modelsRoute([]), saveRoute(bodies));
|
||||
const user = await renderParsing({ provider: 'anthropic', model: 'claude-x', apiKey: '••••••••', vision: 'on' });
|
||||
expect(await screen.findByText('Own connection', { selector: 'span' })).toBeInTheDocument();
|
||||
expect(screen.getByText('claude-x')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Use the shared connection' }));
|
||||
await screen.findByText('AI settings saved');
|
||||
expect(bodies[0]).toEqual({ config: { vision: 'on' } });
|
||||
expect(await screen.findByText('Shared')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-013: the local provider lists installed models and a chip fills the model field', async () => {
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
|
||||
const user = await renderParsing({ provider: 'local', model: 'old' });
|
||||
await screen.findByText('Installed on the server');
|
||||
await user.click(await screen.findByRole('button', { name: 'llama3:8b' }));
|
||||
expect(urls[0]).toBe('http://localhost:11434/v1');
|
||||
expect(screen.getByLabelText('Model')).toHaveValue('llama3:8b');
|
||||
// qwen3.5:4b is installed already, so the recommended row offers Use instead of Pull.
|
||||
await user.click(screen.getByRole('button', { name: 'Use' }));
|
||||
expect(screen.getByLabelText('Model')).toHaveValue('qwen3.5:4b');
|
||||
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("FE-COMP-AI-ADMIN-014: an unreachable Ollama shows the server's reason and Refresh retries", async () => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
calls += 1;
|
||||
return calls === 1 ? HttpResponse.json({ error: 'down' }, { status: 500 }) : HttpResponse.json({ models: [] });
|
||||
})
|
||||
);
|
||||
const user = await renderParsing({ provider: 'local', model: 'm' });
|
||||
await screen.findByText('down');
|
||||
expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Refresh' }));
|
||||
await screen.findByText('No models installed yet. Pull one below.');
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-015: switching providers swaps the address placeholder and the Ollama block, and drops a stale address', async () => {
|
||||
const bodies: unknown[] = [];
|
||||
server.use(modelsRoute([]), saveRoute(bodies));
|
||||
const user = await renderParsing({ provider: 'local', model: 'm', baseUrl: 'http://ollama.lan:11434/v1' });
|
||||
await screen.findByText('Installed on the server');
|
||||
await user.click(screen.getByRole('radio', { name: 'OpenAI' }));
|
||||
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toHaveValue('');
|
||||
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('radio', { name: 'Anthropic' }));
|
||||
// A gateway can stand in for Anthropic too, so the field stays (#2655).
|
||||
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
|
||||
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
|
||||
await user.clear(screen.getByLabelText('Model'));
|
||||
await user.type(screen.getByLabelText('Model'), ' claude-haiku ');
|
||||
await user.type(screen.getByLabelText('API key'), 'sk-ant-live');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('AI settings saved');
|
||||
// The stale local address must not ride along to Anthropic, and the model is trimmed.
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'anthropic', model: 'claude-haiku', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-016: a hand-typed address is asked on blur, a cloud provider asks nothing', async () => {
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(modelsRoute([], urls));
|
||||
const user = await renderParsing({ provider: 'local', model: 'm' });
|
||||
await screen.findByText('Installed on the server');
|
||||
await user.type(screen.getByLabelText('Base URL'), 'http://ollama.lan:11434/v1');
|
||||
await user.tab();
|
||||
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
|
||||
const before = urls.length;
|
||||
await user.click(screen.getByRole('radio', { name: 'OpenAI' }));
|
||||
await user.type(screen.getByLabelText('Base URL'), 'https://proxy.local/v1');
|
||||
await user.tab();
|
||||
expect(urls).toHaveLength(before);
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-017: a pull streams progress and then selects the model', async () => {
|
||||
let pulled: unknown = null;
|
||||
let modelCalls = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
modelCalls += 1;
|
||||
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
|
||||
}),
|
||||
http.post('/api/admin/llm/local/pull', async ({ request }) => {
|
||||
pulled = await request.json();
|
||||
await delay(150);
|
||||
return new HttpResponse(
|
||||
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
|
||||
{ headers: { 'Content-Type': 'application/x-ndjson' } }
|
||||
);
|
||||
})
|
||||
);
|
||||
const user = await renderParsing({ provider: 'local', model: 'm' });
|
||||
await screen.findByText('No models installed yet. Pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
expect(await screen.findAllByText('Pulling…')).not.toHaveLength(0);
|
||||
await screen.findByText('Model pulled');
|
||||
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
|
||||
expect(screen.getByLabelText('Model')).toHaveValue('qwen3.5:4b');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-018: an error frame or a refused pull is reported and the button comes back', async () => {
|
||||
server.use(
|
||||
modelsRoute([]),
|
||||
http.post(
|
||||
'/api/admin/llm/local/pull',
|
||||
() =>
|
||||
new HttpResponse('{"status":"pulling manifest"}\n{"error":"manifest not found"}\n', {
|
||||
headers: { 'Content-Type': 'application/x-ndjson' },
|
||||
})
|
||||
)
|
||||
);
|
||||
const user = await renderParsing({ provider: 'local', model: 'm' });
|
||||
await screen.findByText('No models installed yet. Pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
await screen.findByText('manifest not found');
|
||||
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-019: saving keeps a masked key and reports both outcomes', async () => {
|
||||
const bodies: unknown[] = [];
|
||||
server.use(modelsRoute([]), saveRoute(bodies));
|
||||
const user = await renderParsing({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••' });
|
||||
await screen.findByText('Installed on the server');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('AI settings saved');
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', vision: 'auto' },
|
||||
});
|
||||
server.use(saveRoute(bodies, true));
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Could not save AI settings');
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-020: whether the model reads images saves at once, with the connection as saved', async () => {
|
||||
const bodies: unknown[] = [];
|
||||
server.use(saveRoute(bodies));
|
||||
const user = await renderParsing({
|
||||
provider: 'openai',
|
||||
model: 'gpt-4.1-mini',
|
||||
baseUrl: '',
|
||||
apiKey: 'sk',
|
||||
vision: 'off',
|
||||
});
|
||||
expect(await screen.findByRole('button', { name: 'No' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Yes' }));
|
||||
await screen.findByText('AI settings saved');
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'openai', model: 'gpt-4.1-mini', baseUrl: '', apiKey: 'sk', vision: 'on' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import { Check, Loader2, Sparkles } from 'lucide-react';
|
||||
import React, { useId, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch';
|
||||
import AiConnectPanel, { ProviderMark } from '../../Settings/ai/AiConnectPanel';
|
||||
import { CONNECTION_STATUS } from '../../Settings/ai/aiConnectModel';
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog';
|
||||
import CustomSelect from '../../shared/CustomSelect';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
NEUTRAL_TINT,
|
||||
fs,
|
||||
} from '../../shared/DialogShell';
|
||||
import { NumericInput } from '../../shared/NumericInput';
|
||||
import { EditorField, INPUT, Segmented } from '../../shared/dialogParts';
|
||||
import ParsingSettings from './ParsingSettings';
|
||||
import type { Addon } from './addonModel';
|
||||
import { POLICY_CHOICES, useAiAdminConfig } from './useAiAdminConfig';
|
||||
|
||||
type AiAdmin = ReturnType<typeof useAiAdminConfig>;
|
||||
|
||||
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint';
|
||||
|
||||
/** Who pays, as three rows to pick one from: the labels are too long for a segmented track in a shelf. */
|
||||
function PolicyChoice({ A }: { A: AiAdmin }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className={EYEBROW} style={fs(9.5)}>
|
||||
{t('assistant.admin.policy')}
|
||||
</span>
|
||||
<div role="radiogroup" aria-label={t('assistant.admin.policy')} className="flex flex-col gap-1.5">
|
||||
{POLICY_CHOICES.map((choice) => {
|
||||
const on = A.policy === choice.value;
|
||||
return (
|
||||
<button
|
||||
key={choice.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
onClick={() => A.setPolicy(choice.value)}
|
||||
className={`flex items-center gap-2 rounded-[10px] border px-3 py-2 text-start transition-colors ${on ? 'border-[color:var(--text-primary)] bg-surface-card shadow-sm' : 'border-edge bg-surface-card hover:border-edge-secondary'}`}
|
||||
>
|
||||
<span
|
||||
className={`min-w-0 flex-1 font-medium ${on ? 'text-content' : 'text-content-muted'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t(choice.labelKey)}
|
||||
</span>
|
||||
{on && <Check size={14} strokeWidth={2.4} className="flex-none text-content" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{A.policyHintKey && <SettingsHint>{t(A.policyHintKey)}</SettingsHint>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The connection everyone runs on, once set up: who answers, its model, and replacing or removing it. */
|
||||
function LiveConnection({ A }: { A: AiAdmin }) {
|
||||
const { t } = useTranslation();
|
||||
const { live, provider, models, busy, setModel, replace, askRemove } = A.instance;
|
||||
if (!live) return null;
|
||||
const status = CONNECTION_STATUS[live.status];
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-3 rounded-[12px] border border-edge-faint bg-surface-card p-3 ${busy ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ProviderMark kind={live.kind} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="m-0 truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{provider}
|
||||
</p>
|
||||
<p className="m-0 truncate font-geist text-content-faint" style={fs(11)}>
|
||||
{live.hint ?? live.host}
|
||||
</p>
|
||||
</div>
|
||||
<StatusPill tone={status.tone}>{t(status.labelKey)}</StatusPill>
|
||||
</div>
|
||||
{live.status === 'ok' && (
|
||||
<EditorField label={t('assistant.connect.model')} hint={t('assistant.connect.modelHint')}>
|
||||
<CustomSelect
|
||||
ariaLabel={t('assistant.connect.model')}
|
||||
value={live.model}
|
||||
onChange={(v) => setModel(String(v))}
|
||||
options={models}
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
searchable={models.length > 8}
|
||||
/>
|
||||
</EditorField>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2" style={fs(12.5, 'body')}>
|
||||
<button type="button" onClick={replace} disabled={busy} className={SETTINGS_BUTTON}>
|
||||
{t('common.change')}
|
||||
</button>
|
||||
<button type="button" onClick={askRemove} disabled={busy} className={SETTINGS_BUTTON_DANGER}>
|
||||
{t('assistant.admin.connectionRemove')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstanceConnection({ A }: { A: AiAdmin }) {
|
||||
const { t } = useTranslation();
|
||||
const { live, connection, replacing, cancelReplace, onConnected } = A.instance;
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<span className={EYEBROW} style={fs(9.5)}>
|
||||
{t('assistant.admin.connection')}
|
||||
</span>
|
||||
{!replacing && <LiveConnection A={A} />}
|
||||
{!live && <SettingsHint>{t('assistant.admin.connectionNone')}</SettingsHint>}
|
||||
{A.showPanel && (
|
||||
<AiConnectPanel scope="instance" onConnected={onConnected} prefill={connection?.prefill ?? null} />
|
||||
)}
|
||||
{replacing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelReplace}
|
||||
className={`${SETTINGS_BUTTON} self-start`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The assistant's own limits: turns a day while the instance pays, how long chats are kept, whether it searches the web. */
|
||||
function AssistantLimits({ A }: { A: AiAdmin }) {
|
||||
const { t } = useTranslation();
|
||||
const fieldId = useId();
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{A.instancePays && (
|
||||
<EditorField
|
||||
label={t('assistant.admin.dailyLimit')}
|
||||
htmlFor={`${fieldId}-daily`}
|
||||
hint={t('assistant.admin.dailyLimitHint')}
|
||||
>
|
||||
<NumericInput
|
||||
id={`${fieldId}-daily`}
|
||||
mode="integer"
|
||||
className={`${INPUT} tabular-nums`}
|
||||
value={A.dailyDraft}
|
||||
onValueChange={A.setDailyDraft}
|
||||
onBlur={A.commitDailyLimit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
}}
|
||||
/>
|
||||
</EditorField>
|
||||
)}
|
||||
<EditorField label={t('assistant.admin.retention')}>
|
||||
<CustomSelect
|
||||
ariaLabel={t('assistant.admin.retention')}
|
||||
value={A.retention}
|
||||
onChange={(v) => A.setRetention(String(v))}
|
||||
options={A.retentionOptions}
|
||||
size="sm"
|
||||
/>
|
||||
</EditorField>
|
||||
{A.settings && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-body font-medium text-content">{t('assistant.admin.webSearch')}</span>
|
||||
<ToggleSwitch
|
||||
on={A.settings.webSearch}
|
||||
onToggle={() => A.setWebSearch(!A.settings?.webSearch)}
|
||||
label={t('assistant.admin.webSearch')}
|
||||
/>
|
||||
</div>
|
||||
<SettingsHint>{t('assistant.admin.webSearchHint')}</SettingsHint>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type AiTab = 'connection' | 'assistant' | 'parsing';
|
||||
|
||||
/**
|
||||
* The AI settings behind the AI tile's button, in one dialog so the addon page
|
||||
* stays short: the connection the assistant and AI parsing share (and who pays
|
||||
* for it), the assistant's limits, and how bookings are read. A tab shows only
|
||||
* while its addon is on. Each change saves at once.
|
||||
*/
|
||||
export default function AiSettingsDialog({
|
||||
addons,
|
||||
onClose,
|
||||
}: {
|
||||
addons: Addon[];
|
||||
onClose: () => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
const A = useAiAdminConfig();
|
||||
const assistantOn = addons.some((a) => a.id === 'ai_assistant' && a.enabled);
|
||||
const parsing = addons.find((a) => a.id === 'llm_parsing' && a.enabled) ?? null;
|
||||
const tabs: { value: AiTab; label: string }[] = [
|
||||
{ value: 'connection', label: t('admin.addons.ai.tab.connection') },
|
||||
...(assistantOn ? [{ value: 'assistant' as const, label: t('admin.addons.catalog.ai_assistant.name') }] : []),
|
||||
...(parsing ? [{ value: 'parsing' as const, label: t('admin.addons.catalog.llm_parsing.name') }] : []),
|
||||
];
|
||||
const [tab, setTab] = useState<AiTab>('connection');
|
||||
const shown = tabs.some((x) => x.value === tab) ? tab : 'connection';
|
||||
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
width="detail"
|
||||
align="top"
|
||||
blocked={A.instance.confirmingRemove}
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<Sparkles size={20} strokeWidth={2} />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
eyebrow={t('admin.addons.ai.name')}
|
||||
title={t('admin.addons.ai.setup')}
|
||||
sub={t('admin.addons.ai.dialogSub')}
|
||||
subWraps
|
||||
onClose={onClose}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton variant="primary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
{tabs.length > 1 && (
|
||||
<Segmented<AiTab> label={t('admin.addons.ai.setup')} value={shown} onChange={setTab} options={tabs} fill />
|
||||
)}
|
||||
{!A.loaded ? (
|
||||
<div className="grid place-items-center py-10">
|
||||
<Loader2 size={20} className="animate-spin text-content-faint" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{shown === 'connection' && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PolicyChoice A={A} />
|
||||
{A.instancePays && <InstanceConnection A={A} />}
|
||||
</div>
|
||||
)}
|
||||
{shown === 'assistant' && <AssistantLimits A={A} />}
|
||||
{shown === 'parsing' && parsing && (
|
||||
<ParsingSettings addon={parsing} A={A} onOpenConnection={() => setTab('connection')} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={A.instance.confirmingRemove}
|
||||
onClose={A.instance.cancelRemove}
|
||||
onConfirm={A.instance.remove}
|
||||
title={t('assistant.admin.connectionRemove')}
|
||||
message={t('assistant.connect.disconnectConfirm', { provider: A.instance.provider })}
|
||||
confirmLabel={t('assistant.admin.connectionRemove')}
|
||||
danger
|
||||
/>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared';
|
||||
import { Check, RefreshCw } from 'lucide-react';
|
||||
import React, { useId } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { ProviderMark } from '../../Settings/ai/AiConnectPanel';
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
||||
import { DialogSection, fs } from '../../shared/DialogShell';
|
||||
import { EditorField, INPUT, PANEL, Segmented } from '../../shared/dialogParts';
|
||||
import { type Addon, ANTHROPIC_HINT, MASKED, RECOMMENDED_MODELS } from './addonModel';
|
||||
import type { useAiAdminConfig } from './useAiAdminConfig';
|
||||
import { useLlmParsingConfig } from './useLlmParsingConfig';
|
||||
|
||||
type AiAdmin = ReturnType<typeof useAiAdminConfig>;
|
||||
type Parsing = ReturnType<typeof useLlmParsingConfig>;
|
||||
type MarkKind = Parameters<typeof ProviderMark>[0]['kind'];
|
||||
|
||||
/** Parsing's own providers, with the mark the assistant shows for each. */
|
||||
const OWN_PROVIDERS = [
|
||||
{ value: 'local', labelKey: 'settings.aiParsing.providerLocal', mark: 'custom' },
|
||||
{ value: 'openai', labelKey: 'settings.aiParsing.providerOpenai', mark: 'openai' },
|
||||
{ value: 'anthropic', labelKey: 'settings.aiParsing.providerAnthropic', mark: 'anthropic' },
|
||||
] as const;
|
||||
|
||||
const markOf = (provider: string): MarkKind => OWN_PROVIDERS.find((p) => p.value === provider)?.mark ?? 'custom';
|
||||
|
||||
/** The connection parsing reads with, as one line: a mark, who answers, the model and where it comes from. */
|
||||
function ReadsWith({ mark, title, model, tag }: { mark: MarkKind; title: string; model: string; tag: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-[12px] border border-edge-faint bg-surface-card p-3">
|
||||
<ProviderMark kind={mark} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="m-0 truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{title}
|
||||
</p>
|
||||
<p className="m-0 truncate font-geist text-content-faint" style={fs(11)}>
|
||||
{model}
|
||||
</p>
|
||||
</div>
|
||||
<StatusPill>{tag}</StatusPill>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Which connection parsing reads with now, and the way to the other one. */
|
||||
function ParsingSource({ A, P, onOpenConnection }: { A: AiAdmin; P: Parsing; onOpenConnection: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const live = A.instancePays ? A.instance.live : null;
|
||||
if (P.ownConnection) {
|
||||
const own = OWN_PROVIDERS.find((p) => p.value === P.savedProvider);
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<ReadsWith
|
||||
mark={markOf(P.savedProvider)}
|
||||
title={own ? t(own.labelKey) : P.savedProvider}
|
||||
model={P.savedModel}
|
||||
tag={t('admin.addons.ai.parsing.own')}
|
||||
/>
|
||||
<SettingsHint>{t('admin.addons.ai.parsing.ownHint')}</SettingsHint>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void P.switchToShared()}
|
||||
disabled={P.saving}
|
||||
className={`${SETTINGS_BUTTON} self-start`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t('admin.addons.ai.parsing.useShared')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{live ? (
|
||||
<ReadsWith
|
||||
mark={live.kind}
|
||||
title={A.instance.provider}
|
||||
model={live.model}
|
||||
tag={t('admin.addons.ai.parsing.sharedTag')}
|
||||
/>
|
||||
) : (
|
||||
<SettingsHint>
|
||||
{t(A.policy === 'user' ? 'admin.addons.ai.parsing.perUser' : 'admin.addons.ai.parsing.none')}
|
||||
</SettingsHint>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2" style={fs(12.5, 'body')}>
|
||||
{!live && A.policy !== 'user' && (
|
||||
<button type="button" onClick={onOpenConnection} className={SETTINGS_BUTTON_PRIMARY}>
|
||||
{t('admin.addons.ai.tab.connection')}
|
||||
</button>
|
||||
)}
|
||||
{!P.formOpen && (
|
||||
<button type="button" onClick={P.startOwn} className={SETTINGS_BUTTON}>
|
||||
{t('admin.addons.ai.parsing.setupOwn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The models on the local server to pick from, and the recommended ones to pull. */
|
||||
function LocalModels({ P }: { P: Parsing }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={PANEL}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-geist font-bold uppercase tracking-[.08em] text-content-faint"
|
||||
style={fs(9.5)}
|
||||
>
|
||||
{t('admin.addons.ai.parsing.installed')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void P.loadModels()}
|
||||
disabled={P.loadingModels}
|
||||
className="inline-flex flex-none items-center gap-1 rounded-full px-2 py-0.5 font-medium text-content-muted hover:bg-surface-card hover:text-content disabled:cursor-default disabled:opacity-60"
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
<RefreshCw size={11} strokeWidth={2.2} className={P.loadingModels ? 'animate-spin' : undefined} />
|
||||
{t('admin.addons.ai.parsing.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
{P.modelsErr && (
|
||||
<p className="m-0 break-words text-danger" style={fs(11.5)}>
|
||||
{P.modelsErr}
|
||||
</p>
|
||||
)}
|
||||
{!P.modelsErr && P.installed.length === 0 && !P.loadingModels && (
|
||||
<SettingsHint>{t('admin.addons.ai.parsing.noModels')}</SettingsHint>
|
||||
)}
|
||||
{P.installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{P.installed.map((name) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name}
|
||||
title={name}
|
||||
onClick={() => P.setModel(name)}
|
||||
aria-pressed={P.model === name}
|
||||
className={`max-w-full truncate rounded-full px-2.5 py-[3px] font-geist font-medium transition-colors ${P.model === name ? 'bg-accent text-accent-text' : 'border border-edge bg-surface-card text-content-muted hover:text-content'}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 border-t border-edge-faint pt-2.5">
|
||||
<span className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>
|
||||
{t('admin.addons.ai.parsing.pullTitle')}
|
||||
</span>
|
||||
{RECOMMENDED_MODELS.map((m) => {
|
||||
const here = P.isInstalled(m.id);
|
||||
const chosen = P.model === m.id;
|
||||
return (
|
||||
<div key={m.id} className="min-w-0" title={m.note}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
|
||||
{m.label}
|
||||
</span>
|
||||
{m.recommended && <StatusPill tone="success">{t('assistant.model.recommended')}</StatusPill>}
|
||||
{here ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => P.setModel(m.id)}
|
||||
disabled={chosen}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{chosen ? <Check size={13} strokeWidth={2.4} /> : null}
|
||||
{t(chosen ? 'admin.addons.ai.parsing.selected' : 'admin.addons.ai.parsing.use')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void P.pull(m.id)}
|
||||
disabled={!!P.pulling}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{t(P.pulling === m.id ? 'admin.addons.ai.parsing.pulling' : 'admin.addons.ai.parsing.pull')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{P.pulling === m.id && (
|
||||
<div className="mt-2">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent transition-[width] duration-200"
|
||||
style={{ width: `${P.pullPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>
|
||||
{P.pullStatus}
|
||||
{P.pullPct ? ` ${P.pullPct}%` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A connection of parsing's own: the provider as tiles, its address, key and model. */
|
||||
function OwnConnectionForm({ P }: { P: Parsing }) {
|
||||
const { t } = useTranslation();
|
||||
const fieldId = useId();
|
||||
return (
|
||||
<div className={`flex flex-col gap-3 ${P.saving ? 'opacity-60' : ''}`}>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t('settings.aiParsing.provider')}
|
||||
className="grid grid-cols-3 gap-2 max-sm:grid-cols-1"
|
||||
>
|
||||
{OWN_PROVIDERS.map((p) => {
|
||||
const on = P.provider === p.value;
|
||||
return (
|
||||
<button
|
||||
key={p.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
onClick={() => P.setProvider(p.value)}
|
||||
className={`flex min-w-0 items-center gap-2 rounded-[12px] border p-2.5 text-start transition-colors ${on ? 'border-[color:var(--text-primary)] bg-surface-card shadow-sm' : 'border-edge bg-surface-card hover:border-edge-secondary'}`}
|
||||
>
|
||||
<ProviderMark kind={p.mark} size="sm" />
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate font-medium ${on ? 'text-content' : 'text-content-muted'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t(p.labelKey)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<EditorField
|
||||
label={t('settings.aiParsing.baseUrl')}
|
||||
htmlFor={`${fieldId}-url`}
|
||||
hint={t('settings.aiParsing.baseUrlHint')}
|
||||
>
|
||||
<input
|
||||
id={`${fieldId}-url`}
|
||||
type="url"
|
||||
autoComplete="off"
|
||||
className={INPUT}
|
||||
value={P.baseUrl}
|
||||
onChange={(e) => P.setBaseUrl(e.target.value)}
|
||||
onBlur={() => void P.loadModels()}
|
||||
placeholder={P.baseUrlPlaceholder}
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField
|
||||
label={t('settings.aiParsing.apiKey')}
|
||||
htmlFor={`${fieldId}-key`}
|
||||
hint={P.provider === 'anthropic' ? ANTHROPIC_HINT : t('settings.aiParsing.apiKeyHint')}
|
||||
>
|
||||
<input
|
||||
id={`${fieldId}-key`}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
className={INPUT}
|
||||
value={P.apiKey}
|
||||
onChange={(e) => P.setApiKey(e.target.value)}
|
||||
placeholder={
|
||||
P.apiKey === MASKED ? MASKED : P.provider === 'local' ? t('admin.addons.ai.parsing.apiKeyOptional') : ''
|
||||
}
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField label={t('settings.aiParsing.model')} htmlFor={`${fieldId}-model`}>
|
||||
<input
|
||||
id={`${fieldId}-model`}
|
||||
autoComplete="off"
|
||||
className={`${INPUT} font-geist`}
|
||||
value={P.model}
|
||||
onChange={(e) => P.setModel(e.target.value)}
|
||||
/>
|
||||
</EditorField>
|
||||
{P.provider === 'local' && <LocalModels P={P} />}
|
||||
<div className="flex justify-end gap-2" style={fs(13, 'body')}>
|
||||
{!P.ownConnection && (
|
||||
<button type="button" onClick={P.cancelOwn} disabled={P.saving} className={SETTINGS_BUTTON}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void P.save()}
|
||||
disabled={P.saving || !P.model.trim()}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
>
|
||||
{t(P.saving ? 'common.saving' : 'common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI parsing in the AI dialog: which connection reads the bookings (the shared
|
||||
* one, or one of its own an older install kept), a connection of its own when
|
||||
* wanted, and whether the model reads images. Each part saves on its own.
|
||||
*/
|
||||
export default function ParsingSettings({
|
||||
addon,
|
||||
A,
|
||||
onOpenConnection,
|
||||
}: {
|
||||
addon: Addon;
|
||||
A: AiAdmin;
|
||||
onOpenConnection: () => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const P = useLlmParsingConfig(addon);
|
||||
const visionOptions = LLM_VISION_MODES.map((value) => ({ value, label: t(`admin.addons.llm.vision.${value}`) }));
|
||||
const local =
|
||||
(P.ownConnection ? P.savedProvider : A.instance.live?.kind === 'custom' ? 'local' : 'cloud') === 'local';
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<DialogSection label={t('admin.addons.ai.parsing.readsWith')}>
|
||||
<ParsingSource A={A} P={P} onOpenConnection={onOpenConnection} />
|
||||
</DialogSection>
|
||||
{P.formOpen && (
|
||||
<DialogSection label={t('admin.addons.ai.parsing.own')}>
|
||||
<OwnConnectionForm P={P} />
|
||||
</DialogSection>
|
||||
)}
|
||||
<DialogSection label={t('settings.aiParsing.multimodal')}>
|
||||
<Segmented<LlmVision>
|
||||
label={t('settings.aiParsing.multimodal')}
|
||||
value={P.vision}
|
||||
onChange={(v) => P.setVision(asLlmVision(v))}
|
||||
options={visionOptions}
|
||||
fill
|
||||
/>
|
||||
<div className="mt-1.5">
|
||||
<SettingsHint>
|
||||
{t(local ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}
|
||||
</SettingsHint>
|
||||
</div>
|
||||
</DialogSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Cable, type LucideIcon, MessagesSquare, ScanText } from 'lucide-react';
|
||||
import type { Addon } from './addonModel';
|
||||
|
||||
/**
|
||||
* The three addons that talk to a language model, shown as one AI tile in the
|
||||
* integration column: the assistant in the trip, AI parsing (booking import)
|
||||
* and MCP (TREK inside other AI apps). Their ids, rows and settings stay their
|
||||
* own; only the page groups them. Assistant and parsing share one connection.
|
||||
*/
|
||||
const AI_ADDON_IDS = ['ai_assistant', 'llm_parsing', 'mcp'] as const;
|
||||
export type AiAddonId = (typeof AI_ADDON_IDS)[number];
|
||||
|
||||
/** Each one's glyph, keyed by id: the icon column of an existing install still holds what it was seeded with. */
|
||||
export const AI_ADDON_ICONS: Record<AiAddonId, LucideIcon> = {
|
||||
ai_assistant: MessagesSquare,
|
||||
llm_parsing: ScanText,
|
||||
mcp: Cable,
|
||||
};
|
||||
|
||||
export function isAiAddon(addon: Addon): boolean {
|
||||
return (AI_ADDON_IDS as readonly string[]).includes(addon.id);
|
||||
}
|
||||
|
||||
/** The AI addons this install has, in the tile's order. */
|
||||
export function aiAddonsOf(addons: Addon[]): Addon[] {
|
||||
return AI_ADDON_IDS.flatMap((id) => addons.find((a) => a.id === id) ?? []);
|
||||
}
|
||||
|
||||
/** Whether the shared connection is used at all: the assistant or AI parsing is on. */
|
||||
export function usesConnection(addons: Addon[]): boolean {
|
||||
return addons.some((a) => (a.id === 'ai_assistant' || a.id === 'llm_parsing') && a.enabled);
|
||||
}
|
||||
@@ -26,6 +26,8 @@ const ADDONS = [
|
||||
addon('llm_parsing', 'integration', false),
|
||||
addon('immich', 'photo_provider', false),
|
||||
addon('gdrive', 'document_provider'),
|
||||
addon('airtrail', 'integration', false),
|
||||
addon('mcp', 'integration'),
|
||||
];
|
||||
|
||||
let loadAddons: ReturnType<typeof vi.fn<() => Promise<void>>>;
|
||||
@@ -60,14 +62,16 @@ function mapSwitch(manager: ReturnType<typeof useAddonManager>, key: 'hiking' |
|
||||
}
|
||||
|
||||
describe('useAddonManager', () => {
|
||||
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration and the two shelves', async () => {
|
||||
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration, AI and the two shelves', async () => {
|
||||
const { result, rerender } = await loaded();
|
||||
rerender();
|
||||
expect(adminApi.addons).toHaveBeenCalledTimes(1);
|
||||
const ids = (list: { id?: string; key?: string }[]) => list.map((a) => a.id ?? a.key);
|
||||
expect(ids(result.current.tripAddons)).toEqual(['todo', 'documents']);
|
||||
expect(ids(result.current.globalAddons)).toEqual(['journey']);
|
||||
expect(ids(result.current.integrationAddons)).toEqual(['llm_parsing']);
|
||||
// The AI addons leave the integration list for their own tile, in its order.
|
||||
expect(ids(result.current.integrationAddons)).toEqual(['airtrail']);
|
||||
expect(ids(result.current.aiAddons)).toEqual(['llm_parsing', 'mcp']);
|
||||
expect(result.current.providerOptions).toEqual([
|
||||
expect.objectContaining({ key: 'immich', label: 'immich', description: 'immich addon', enabled: false }),
|
||||
]);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useTranslation } from '../../../i18n';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel';
|
||||
import { aiAddonsOf, isAiAddon } from './aiAddons';
|
||||
|
||||
/** A switch under Tours as the server keeps it, read into `set`; `onError` tells the admin it could not be read. */
|
||||
function loadToursFlag(
|
||||
@@ -139,7 +140,9 @@ export function useAddonManager() {
|
||||
|
||||
const tripAddons = addons.filter((a) => a.type === 'trip' && !isPhotosAddon(a));
|
||||
const globalAddons = addons.filter((a) => a.type === 'global');
|
||||
const integrationAddons = addons.filter((a) => a.type === 'integration');
|
||||
// The AI addons leave the list to sit together on one tile at its head.
|
||||
const integrationAddons = addons.filter((a) => a.type === 'integration' && !isAiAddon(a));
|
||||
const aiAddons = aiAddonsOf(addons);
|
||||
const providerOptions = addons.filter((a) => a.type === 'photo_provider').map(asOption);
|
||||
// No credential form under these, unlike the photo providers: a document connection
|
||||
// belongs to a trip and is entered there. The admin only decides whether a provider
|
||||
@@ -153,6 +156,7 @@ export function useAddonManager() {
|
||||
tripAddons,
|
||||
globalAddons,
|
||||
integrationAddons,
|
||||
aiAddons,
|
||||
providerOptions,
|
||||
documentProviderOptions,
|
||||
toursWanderer,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import type { Addon } from './addonModel';
|
||||
import { usesConnection } from './aiAddons';
|
||||
|
||||
/**
|
||||
* The AI tile behind both admin shells: its three switches, how many are on,
|
||||
* and the settings dialog. The dialog opens by itself when OpenRouter's
|
||||
* sign-in returns here (`?ai=openrouter`), since the connection form that
|
||||
* finishes it lives inside.
|
||||
*/
|
||||
export function useAiAddonTile(aiAddons: Addon[]) {
|
||||
const [params] = useSearchParams();
|
||||
const [open, setOpen] = useState(() => params.get('ai') === 'openrouter');
|
||||
const canSetUp = usesConnection(aiAddons);
|
||||
return {
|
||||
enabledCount: aiAddons.filter((a) => a.enabled).length,
|
||||
canSetUp,
|
||||
dialogOpen: open && canSetUp,
|
||||
openDialog: () => setOpen(true),
|
||||
closeDialog: () => setOpen(false),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// FE-HOOK-AI-ADMIN-001 to FE-HOOK-AI-ADMIN-007: the AI assistant's settings and connection for everyone.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
|
||||
import { MASKED_SETTING_VALUE } from '@trek/shared';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
status: vi.fn(),
|
||||
adminSettings: vi.fn(),
|
||||
saveAdminSettings: vi.fn(),
|
||||
adminConnection: vi.fn(),
|
||||
saveAdminConnection: vi.fn(),
|
||||
removeAdminConnection: vi.fn(),
|
||||
adminModels: vi.fn(),
|
||||
}));
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
const { useAiAdminConfig } = await import('./useAiAdminConfig');
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
createElement(
|
||||
MemoryRouter,
|
||||
{ initialEntries: ['/admin?tab=addons'] },
|
||||
createElement(TranslationProvider, null, children)
|
||||
);
|
||||
|
||||
const SETTINGS: AiAdminSettings = {
|
||||
policy: 'instance_or_user',
|
||||
dailyTurns: 50,
|
||||
webSearch: true,
|
||||
retention: { mode: 'days', days: 90 },
|
||||
};
|
||||
const NONE: AiAdminConnectionResponse = { connection: null, prefill: null };
|
||||
const LIVE: AiAdminConnectionResponse = {
|
||||
connection: {
|
||||
scope: 'instance',
|
||||
kind: 'anthropic',
|
||||
model: 'claude-sonnet',
|
||||
hint: '…a1b2',
|
||||
status: 'ok',
|
||||
host: 'api.anthropic.com',
|
||||
baseUrl: null,
|
||||
},
|
||||
prefill: null,
|
||||
};
|
||||
|
||||
async function renderLoaded() {
|
||||
const view = renderHook(() => useAiAdminConfig(), { wrapper });
|
||||
await waitFor(() => expect(view.result.current.loaded).toBe(true));
|
||||
return view;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
repo.status.mockRejectedValue(new Error('not needed'));
|
||||
repo.adminSettings.mockResolvedValue(SETTINGS);
|
||||
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
|
||||
repo.adminConnection.mockResolvedValue(NONE);
|
||||
repo.saveAdminConnection.mockResolvedValue(LIVE);
|
||||
repo.removeAdminConnection.mockResolvedValue(undefined);
|
||||
repo.adminModels.mockResolvedValue([{ id: 'claude-sonnet', label: 'Claude Sonnet', supportsTools: true }]);
|
||||
});
|
||||
|
||||
describe('useAiAdminConfig', () => {
|
||||
it('FE-HOOK-AI-ADMIN-001: loads the settings and, without a connection, opens the setup for everyone', async () => {
|
||||
const { result } = await renderLoaded();
|
||||
expect(result.current.policy).toBe('instance_or_user');
|
||||
expect(result.current.policyHintKey).toBe('assistant.admin.policy.instanceOrUserHint');
|
||||
expect(result.current.instancePays).toBe(true);
|
||||
expect(result.current.showPanel).toBe(true);
|
||||
expect(result.current.dailyDraft).toBe('50');
|
||||
expect(result.current.retention).toBe('90');
|
||||
expect(result.current.retentionOptions.map((o) => o.label)).toEqual(['30 days', '90 days', '365 days', 'Forever']);
|
||||
expect(repo.status).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-ADMIN-002: a policy change saves at once, and a refused one is taken back', async () => {
|
||||
const { result } = await renderLoaded();
|
||||
await act(async () => result.current.setPolicy('user'));
|
||||
await waitFor(() => expect(result.current.policy).toBe('user'));
|
||||
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, policy: 'user' });
|
||||
expect(result.current.instancePays).toBe(false);
|
||||
|
||||
repo.saveAdminSettings.mockRejectedValueOnce(new Error('nope'));
|
||||
await act(async () => result.current.setPolicy('instance'));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1));
|
||||
expect(result.current.policy).toBe('user');
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-ADMIN-003: the daily limit is kept within 1 to 10000 and saved when the field is left', async () => {
|
||||
const { result } = await renderLoaded();
|
||||
act(() => result.current.setDailyDraft('20000'));
|
||||
await act(async () => result.current.commitDailyLimit());
|
||||
expect(result.current.dailyDraft).toBe('10000');
|
||||
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, dailyTurns: 10000 });
|
||||
|
||||
vi.clearAllMocks();
|
||||
act(() => result.current.setDailyDraft(''));
|
||||
await act(async () => result.current.commitDailyLimit());
|
||||
expect(result.current.dailyDraft).toBe('10000');
|
||||
expect(repo.saveAdminSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-ADMIN-004: a retention period set elsewhere stays selectable', async () => {
|
||||
repo.adminSettings.mockResolvedValue({ ...SETTINGS, retention: { mode: 'days', days: 120 } });
|
||||
const { result } = await renderLoaded();
|
||||
expect(result.current.retentionOptions.map((o) => o.value)).toEqual(['30', '90', '120', '365', 'forever']);
|
||||
await act(async () => result.current.setRetention('forever'));
|
||||
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, retention: { mode: 'forever' } });
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-ADMIN-005: another model keeps the stored key by sending the mask', async () => {
|
||||
repo.adminConnection.mockResolvedValue(LIVE);
|
||||
const { result } = await renderLoaded();
|
||||
expect(result.current.showPanel).toBe(false);
|
||||
await waitFor(() => expect(result.current.instance.models).toHaveLength(1));
|
||||
await act(async () => result.current.instance.setModel('claude-haiku'));
|
||||
await waitFor(() => expect(repo.saveAdminConnection).toHaveBeenCalledTimes(1));
|
||||
expect(repo.saveAdminConnection).toHaveBeenCalledWith({
|
||||
kind: 'anthropic',
|
||||
apiKey: MASKED_SETTING_VALUE,
|
||||
model: 'claude-haiku',
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-ADMIN-006: an own server without a key sends none', async () => {
|
||||
repo.adminConnection.mockResolvedValue({
|
||||
connection: {
|
||||
scope: 'instance',
|
||||
kind: 'custom',
|
||||
model: 'llama3',
|
||||
hint: null,
|
||||
status: 'ok',
|
||||
host: 'ollama:11434',
|
||||
baseUrl: 'http://ollama:11434/v1',
|
||||
},
|
||||
prefill: null,
|
||||
});
|
||||
const { result } = await renderLoaded();
|
||||
await act(async () => result.current.instance.setModel('qwen3:8b'));
|
||||
await waitFor(() => expect(repo.saveAdminConnection).toHaveBeenCalledTimes(1));
|
||||
expect(repo.saveAdminConnection).toHaveBeenCalledWith({
|
||||
kind: 'custom',
|
||||
baseUrl: 'http://ollama:11434/v1',
|
||||
model: 'qwen3:8b',
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-ADMIN-007: removing asks first, then shows the setup again; replacing opens it beside the old one', async () => {
|
||||
repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue(NONE);
|
||||
const { result } = await renderLoaded();
|
||||
act(() => result.current.instance.replace());
|
||||
expect(result.current.showPanel).toBe(true);
|
||||
act(() => result.current.instance.cancelReplace());
|
||||
expect(result.current.showPanel).toBe(false);
|
||||
|
||||
act(() => result.current.instance.askRemove());
|
||||
expect(result.current.instance.confirmingRemove).toBe(true);
|
||||
act(() => result.current.instance.remove());
|
||||
await waitFor(() => expect(result.current.instance.live).toBeNull());
|
||||
expect(repo.removeAdminConnection).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.instance.confirmingRemove).toBe(false);
|
||||
expect(result.current.showPanel).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import {
|
||||
MASKED_SETTING_VALUE,
|
||||
type AiAdminConnectionResponse,
|
||||
type AiAdminSettings,
|
||||
type AiInstanceConnectionRequest,
|
||||
type AiPolicy,
|
||||
type AiRetention,
|
||||
} from '@trek/shared';
|
||||
import { useEffect, useEffectEvent, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { aiConnectionsRepo } from '../../../repo/aiConnectionsRepo';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { providerLabel } from '../../Assistant/providerCatalog';
|
||||
import { connectErrorKey } from '../../Settings/ai/aiConnectModel';
|
||||
import { useOpenrouterReturn } from '../../Settings/ai/useAiConnect';
|
||||
import { modelOptions, useAiModels } from '../../Settings/ai/useAiConnections';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
|
||||
/** Who pays, as the admin reads it ("I pay" rather than "the instance"); the first is the default. */
|
||||
export const POLICY_CHOICES: readonly { value: AiPolicy; labelKey: string; hintKey: string }[] = [
|
||||
{
|
||||
value: 'instance_or_user',
|
||||
labelKey: 'assistant.admin.policy.instanceOrUser',
|
||||
hintKey: 'assistant.admin.policy.instanceOrUserHint',
|
||||
},
|
||||
{ value: 'instance', labelKey: 'assistant.admin.policy.instance', hintKey: 'assistant.admin.policy.instanceHint' },
|
||||
{ value: 'user', labelKey: 'assistant.admin.policy.user', hintKey: 'assistant.admin.policy.userHint' },
|
||||
];
|
||||
|
||||
const RETENTION_DAYS = [30, 90, 365];
|
||||
const FOREVER = 'forever';
|
||||
const MAX_DAILY_TURNS = 10_000;
|
||||
|
||||
/** A retention as the select's value: its days, or `forever`. */
|
||||
function retentionValue(retention: AiRetention): string {
|
||||
return retention.mode === 'forever' ? FOREVER : String(retention.days);
|
||||
}
|
||||
|
||||
function retentionOf(value: string): AiRetention {
|
||||
return value === FOREVER ? { mode: 'forever' } : { mode: 'days', days: Number(value) };
|
||||
}
|
||||
|
||||
/** Policy, daily limit, retention and web search: each change is saved at once and taken back when the server refuses it. */
|
||||
function useAdminSettings(onSaved: () => void) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [settings, setSettings] = useState<AiAdminSettings | null>(null);
|
||||
const [dailyDraft, setDailyDraft] = useState('');
|
||||
|
||||
const adopt = (next: AiAdminSettings) => {
|
||||
setSettings(next);
|
||||
setDailyDraft(String(next.dailyTurns));
|
||||
};
|
||||
|
||||
const save = async (patch: Partial<AiAdminSettings>) => {
|
||||
if (!settings) return;
|
||||
const before = settings;
|
||||
setSettings({ ...settings, ...patch });
|
||||
try {
|
||||
adopt(await aiConnectionsRepo.saveAdminSettings({ ...settings, ...patch }));
|
||||
onSaved();
|
||||
} catch {
|
||||
adopt(before);
|
||||
toast.error(t('assistant.error.generic'));
|
||||
}
|
||||
};
|
||||
|
||||
/** The typed limit, kept within what the server takes, saved when the field is left. */
|
||||
const commitDailyLimit = () => {
|
||||
if (!settings) return;
|
||||
const typed = Number.parseInt(dailyDraft, 10);
|
||||
const next = Number.isFinite(typed) ? Math.min(MAX_DAILY_TURNS, Math.max(1, typed)) : settings.dailyTurns;
|
||||
setDailyDraft(String(next));
|
||||
if (next !== settings.dailyTurns) void save({ dailyTurns: next });
|
||||
};
|
||||
|
||||
const current = settings ? retentionValue(settings.retention) : '';
|
||||
// A period set some other way stays selectable instead of reading as blank.
|
||||
const unlisted = current !== '' && current !== FOREVER && !RETENTION_DAYS.includes(Number(current));
|
||||
const days = unlisted ? [...RETENTION_DAYS, Number(current)].sort((a, b) => a - b) : RETENTION_DAYS;
|
||||
|
||||
return {
|
||||
settings,
|
||||
adopt,
|
||||
setPolicy: (policy: AiPolicy) => void save({ policy }),
|
||||
dailyDraft,
|
||||
setDailyDraft,
|
||||
commitDailyLimit,
|
||||
retention: current,
|
||||
retentionOptions: [
|
||||
...days.map((d) => ({ value: String(d), label: t('assistant.admin.retentionDays', { count: d }) })),
|
||||
{ value: FOREVER, label: t('assistant.admin.retentionForever') },
|
||||
],
|
||||
setRetention: (value: string) => void save({ retention: retentionOf(value) }),
|
||||
setWebSearch: (webSearch: boolean) => void save({ webSearch }),
|
||||
};
|
||||
}
|
||||
|
||||
/** The connection for everyone: shown, its model switched, replaced or removed. */
|
||||
function useInstanceConnection(onChanged: () => void) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [connection, setConnection] = useState<AiAdminConnectionResponse | null>(null);
|
||||
const [replacing, setReplacing] = useState(false);
|
||||
const [confirmingRemove, setConfirmingRemove] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const live = connection?.connection ?? null;
|
||||
const provider = live ? providerLabel(live.kind, live.host) : '';
|
||||
const models = useAiModels(
|
||||
live?.status === 'ok' ? () => aiConnectionsRepo.adminModels() : null,
|
||||
live ? `${live.kind}@${live.host}` : ''
|
||||
);
|
||||
|
||||
const act = async (action: () => Promise<AiAdminConnectionResponse>, doneKey: string | null) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setConnection(await action());
|
||||
onChanged();
|
||||
if (doneKey) toast.success(t(doneKey));
|
||||
} catch (err) {
|
||||
toast.error(t(connectErrorKey(err), { provider }));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Another model on the same connection: the stored key is kept by sending the mask in its place. */
|
||||
const setModel = (model: string) => {
|
||||
if (!live || live.kind === 'chatgpt') return;
|
||||
const request: AiInstanceConnectionRequest =
|
||||
live.kind === 'custom'
|
||||
? { kind: 'custom', baseUrl: live.baseUrl ?? '', model, ...(live.hint ? { apiKey: MASKED_SETTING_VALUE } : {}) }
|
||||
: { kind: live.kind, apiKey: MASKED_SETTING_VALUE, model };
|
||||
void act(() => aiConnectionsRepo.saveAdminConnection(request), 'common.saved');
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
setConfirmingRemove(false);
|
||||
void act(async () => {
|
||||
await aiConnectionsRepo.removeAdminConnection();
|
||||
return aiConnectionsRepo.adminConnection();
|
||||
}, null);
|
||||
};
|
||||
|
||||
const onConnected = () => {
|
||||
setReplacing(false);
|
||||
void act(() => aiConnectionsRepo.adminConnection(), null);
|
||||
};
|
||||
|
||||
return {
|
||||
connection,
|
||||
setConnection,
|
||||
live,
|
||||
provider,
|
||||
models: live
|
||||
? modelOptions(models, live.model, {
|
||||
free: t('assistant.model.free'),
|
||||
recommended: t('assistant.model.recommended'),
|
||||
})
|
||||
: [],
|
||||
busy,
|
||||
setModel,
|
||||
replacing,
|
||||
replace: () => setReplacing(true),
|
||||
cancelReplace: () => setReplacing(false),
|
||||
onConnected,
|
||||
confirmingRemove,
|
||||
askRemove: () => setConfirmingRemove(true),
|
||||
cancelRemove: () => setConfirmingRemove(false),
|
||||
remove,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The AI assistant's admin settings behind both admin shells, on the addon's
|
||||
* own tile: who pays, the connection everyone runs on, the daily limit while
|
||||
* the instance pays, and how long chats are kept. The assistant's status is
|
||||
* reloaded after every change, so the launcher appears or goes at once.
|
||||
*/
|
||||
export function useAiAdminConfig() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||
const refreshStatus = () => void loadStatus();
|
||||
const form = useAdminSettings(refreshStatus);
|
||||
const instance = useInstanceConnection(refreshStatus);
|
||||
useOpenrouterReturn(instance.onConnected);
|
||||
|
||||
const loadOnMount = useEffectEvent((isCancelled: () => boolean) => {
|
||||
Promise.all([aiConnectionsRepo.adminSettings(), aiConnectionsRepo.adminConnection()]).then(
|
||||
([settings, connection]) => {
|
||||
if (isCancelled()) return;
|
||||
form.adopt(settings);
|
||||
instance.setConnection(connection);
|
||||
},
|
||||
() => {
|
||||
if (!isCancelled()) toast.error(t('assistant.error.generic'));
|
||||
}
|
||||
);
|
||||
// The connect panel reads whether OpenRouter can return here from the status.
|
||||
refreshStatus();
|
||||
});
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadOnMount(() => cancelled);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const policy = form.settings?.policy ?? null;
|
||||
return {
|
||||
...form,
|
||||
instance,
|
||||
loaded: form.settings !== null && instance.connection !== null,
|
||||
policy,
|
||||
policyHintKey: POLICY_CHOICES.find((c) => c.value === policy)?.hintKey ?? null,
|
||||
/** Policy `user` charges nothing to the instance: no connection and no limit to set. */
|
||||
instancePays: policy !== null && policy !== 'user',
|
||||
showPanel: instance.live === null || instance.replacing,
|
||||
};
|
||||
}
|
||||
@@ -5,10 +5,14 @@ import { adminApi } from '../../../api/client';
|
||||
import { type Addon, DEFAULT_OLLAMA_URL, MASKED } from './addonModel';
|
||||
import { useLlmParsingConfig } from './useLlmParsingConfig';
|
||||
|
||||
// FE-HOOK-LLMCFG-001 to FE-HOOK-LLMCFG-011
|
||||
// FE-HOOK-LLMCFG-001 to FE-HOOK-LLMCFG-013
|
||||
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
vi.mock('../../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
|
||||
|
||||
/** A local connection of parsing's own, so the form is open and lists the server's models. */
|
||||
const OWN_LOCAL = { provider: 'local', model: 'm' };
|
||||
|
||||
function llm(config?: Record<string, unknown>): Addon {
|
||||
return {
|
||||
@@ -45,7 +49,7 @@ describe('useLlmParsingConfig', () => {
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-002: the local provider lists the installed models from the default URL', async () => {
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm()));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
||||
await waitFor(() => expect(result.current.installed).toEqual(['qwen3.5:4b']));
|
||||
expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL);
|
||||
expect(result.current.isInstalled('qwen3.5:4b')).toBe(true);
|
||||
@@ -54,7 +58,7 @@ describe('useLlmParsingConfig', () => {
|
||||
|
||||
it('FE-HOOK-LLMCFG-003: a cloud provider lists nothing; switched to local it starts from the default, then uses the typed URL', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLlmParsingConfig(llm({ provider: 'openai', baseUrl: ' http://box:11434/v1 ' }))
|
||||
useLlmParsingConfig(llm({ provider: 'openai', model: 'gpt', baseUrl: ' http://box:11434/v1 ' }))
|
||||
);
|
||||
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
|
||||
await act(() => result.current.loadModels());
|
||||
@@ -69,13 +73,13 @@ describe('useLlmParsingConfig', () => {
|
||||
|
||||
it('FE-HOOK-LLMCFG-004: an unreachable server shows its message and empties the list', async () => {
|
||||
vi.mocked(adminApi.llmLocalModels).mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm()));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
||||
await waitFor(() => expect(result.current.modelsErr).toBe('ECONNREFUSED'));
|
||||
expect(result.current.installed).toEqual([]);
|
||||
expect(result.current.loadingModels).toBe(false);
|
||||
vi.mocked(adminApi.llmLocalModels).mockRejectedValue('nope');
|
||||
await act(() => result.current.loadModels());
|
||||
expect(result.current.modelsErr).toBe('Could not reach the local LLM server');
|
||||
expect(result.current.modelsErr).toBe('admin.addons.ai.parsing.unreachable');
|
||||
});
|
||||
|
||||
it("FE-HOOK-LLMCFG-011: a refused list shows the server's reason, not the status line (#2649)", async () => {
|
||||
@@ -83,7 +87,7 @@ describe('useLlmParsingConfig', () => {
|
||||
response: { status: 502, data: { error: 'Local LLM server rejected the API key (401)' } },
|
||||
});
|
||||
vi.mocked(adminApi.llmLocalModels).mockRejectedValue(refused);
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'local' })));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
||||
await waitFor(() => expect(result.current.modelsErr).toBe('Local LLM server rejected the API key (401)'));
|
||||
});
|
||||
|
||||
@@ -94,14 +98,14 @@ describe('useLlmParsingConfig', () => {
|
||||
progress = onProgress;
|
||||
return new Promise<void>((r) => (finish = r));
|
||||
});
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm()));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
||||
await waitFor(() => expect(result.current.installed).toEqual(['qwen3.5:4b']));
|
||||
let pending!: Promise<void>;
|
||||
act(() => {
|
||||
pending = result.current.pull('qwen3.5:4b');
|
||||
});
|
||||
expect(result.current.pulling).toBe('qwen3.5:4b');
|
||||
expect(result.current.pullStatus).toBe('starting…');
|
||||
expect(result.current.pullStatus).toBe('admin.addons.ai.parsing.pulling');
|
||||
act(() => progress({ status: 'downloading', total: 200, completed: 50 }));
|
||||
expect(result.current.pullStatus).toBe('downloading');
|
||||
expect(result.current.pullPct).toBe(25);
|
||||
@@ -109,7 +113,7 @@ describe('useLlmParsingConfig', () => {
|
||||
finish();
|
||||
await pending;
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Model pulled');
|
||||
expect(toast.success).toHaveBeenCalledWith('admin.addons.ai.parsing.pulled');
|
||||
expect(result.current.model).toBe('qwen3.5:4b');
|
||||
expect(adminApi.llmLocalModels).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.pulling).toBeNull();
|
||||
@@ -136,7 +140,7 @@ describe('useLlmParsingConfig', () => {
|
||||
expect(toast.error).toHaveBeenCalledWith('manifest unknown');
|
||||
vi.mocked(adminApi.llmLocalPull).mockRejectedValue('nope');
|
||||
await act(() => result.current.pull('x'));
|
||||
expect(toast.error).toHaveBeenLastCalledWith('Pull failed');
|
||||
expect(toast.error).toHaveBeenLastCalledWith('admin.addons.ai.parsing.pullFailed');
|
||||
expect(result.current.pulling).toBeNull();
|
||||
});
|
||||
|
||||
@@ -148,18 +152,17 @@ describe('useLlmParsingConfig', () => {
|
||||
expect(adminApi.updateAddon).toHaveBeenCalledWith('llm_parsing', {
|
||||
config: { provider: 'openai', model: 'gpt-4o', baseUrl: 'https://o/v1', apiKey: MASKED, vision: 'auto' },
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Saved');
|
||||
expect(toast.success).toHaveBeenCalledWith('settings.aiParsing.toast.saved');
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it("FE-HOOK-LLMCFG-009: switching to Anthropic drops the previous provider's base URL", async () => {
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai', baseUrl: 'https://o/v1' })));
|
||||
act(() => {
|
||||
result.current.setProvider('anthropic');
|
||||
result.current.setVision('off');
|
||||
});
|
||||
await act(async () => result.current.setVision('off'));
|
||||
act(() => result.current.setProvider('anthropic'));
|
||||
await act(() => result.current.save());
|
||||
expect(vi.mocked(adminApi.updateAddon).mock.calls[0][1]).toEqual({
|
||||
const calls = vi.mocked(adminApi.updateAddon).mock.calls;
|
||||
expect(calls[calls.length - 1]?.[1]).toEqual({
|
||||
config: { provider: 'anthropic', model: '', baseUrl: '', apiKey: '', vision: 'off' },
|
||||
});
|
||||
});
|
||||
@@ -212,7 +215,35 @@ describe('useLlmParsingConfig', () => {
|
||||
vi.mocked(adminApi.updateAddon).mockRejectedValue(new Error('x'));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai' })));
|
||||
await act(() => result.current.save());
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to save');
|
||||
expect(toast.error).toHaveBeenCalledWith('settings.aiParsing.toast.saveError');
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-012: without its own connection the form stays shut and asks the server nothing until opened', async () => {
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ vision: 'on' })));
|
||||
expect(result.current.ownConnection).toBe(false);
|
||||
expect(result.current.formOpen).toBe(false);
|
||||
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
|
||||
act(() => result.current.startOwn());
|
||||
expect(result.current.formOpen).toBe(true);
|
||||
await waitFor(() => expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL));
|
||||
// The vision setting alone is saved without a connection.
|
||||
await act(async () => result.current.setVision('off'));
|
||||
expect(adminApi.updateAddon).toHaveBeenLastCalledWith('llm_parsing', { config: { vision: 'off' } });
|
||||
act(() => result.current.cancelOwn());
|
||||
expect(result.current.formOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-013: going back to the shared connection drops the own one and keeps the vision setting', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLlmParsingConfig(llm({ provider: 'anthropic', model: 'claude-x', apiKey: MASKED, vision: 'on' }))
|
||||
);
|
||||
expect(result.current.ownConnection).toBe(true);
|
||||
expect(result.current.savedModel).toBe('claude-x');
|
||||
await act(() => result.current.switchToShared());
|
||||
expect(adminApi.updateAddon).toHaveBeenCalledWith('llm_parsing', { config: { vision: 'on' } });
|
||||
expect(result.current.ownConnection).toBe(false);
|
||||
expect(result.current.formOpen).toBe(false);
|
||||
expect(result.current.model).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||