fix(frontend): harden the streaming ui and extract copy and constants
This commit is contained in:
parent
036ef3cc6f
commit
a3af8f45cc
36 changed files with 1662 additions and 226 deletions
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
|
|
@ -35,4 +35,5 @@ jobs:
|
|||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- run: npm run lint
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
|
|
|
|||
|
|
@ -239,3 +239,37 @@ Wat ik heb laten vallen of uitgesteld:
|
|||
en de server bewaart niets; een history-lijst kan later contract-schoon
|
||||
via localStorage, zonder server-state. Potentiele extra als er tijd
|
||||
over is, geen onderdeel van de kern.
|
||||
|
||||
### Frontend: review-pass op de conversie
|
||||
|
||||
De findings hieronder komen uit twee richtingen: een adversarial review
|
||||
die ik op de conversie heb laten draaien, en mijn eigen review van de
|
||||
code. Beide sets zijn in dezelfde pass verwerkt.
|
||||
|
||||
Wat ik deed:
|
||||
|
||||
- Uit de adversarial review. De grootste finding: API-strings gingen
|
||||
ongefilterd een href in (URL-injectie mogelijk!); links worden nu alleen
|
||||
gezet voor https-URLs op open.spotify.com.
|
||||
- Verder: een expliciete phase machine op de event-stream (metadata, dan
|
||||
tracks/warnings, dan precies 1 keer done of error; al het andere is een
|
||||
transport failure), een race gefixt waarbij een snelle nieuwe vraag een
|
||||
afgeronde beurt als cancelled kon markeren, response-body cancellation
|
||||
op parse-fouten, query-invoer begrensd op de request-limiet, warnings
|
||||
in een live region voor screenreaders, focus-herstel van het dev panel,
|
||||
en contrast op WCAG AA gebracht.
|
||||
- Een vitest-suite toegevoegd (26 tests) die de NDJSON-parser op
|
||||
gefragmenteerde chunks, error-terminaliteit, de request-caps en
|
||||
abort-races vastlegt; draait mee in CI.
|
||||
- Alle user-facing tekst uit de componenten geextraheerd naar 1 getypeerde
|
||||
messages-module (i18n-klaar zonder er nu een framework voor mee te nemen),
|
||||
alle magic numbers vervangen door
|
||||
benoemde constants met per waarde een reden (de request-caps verwijzen
|
||||
expliciet naar de backend schema-bounds), en dichtgeschreven TypeScript
|
||||
herschreven naar leesbare code zonder gedragsverandering.
|
||||
|
||||
Waarom:
|
||||
|
||||
- Machine-geconverteerde UI-code krijgt dezelfde behandeling als de
|
||||
backend: een review-pass met concrete findings en een test-suite die de
|
||||
contractkritische randen vastzet, voordat het richting main gaat.
|
||||
|
|
|
|||
419
frontend/package-lock.json
generated
419
frontend/package-lock.json
generated
|
|
@ -18,10 +18,12 @@
|
|||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-plugin-vue": "^10.10.0",
|
||||
"happy-dom": "^20.11.2",
|
||||
"jiti": "^2.7.0",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0",
|
||||
"vitest": "^4.1.10",
|
||||
"vue-tsc": "^3.3.8"
|
||||
}
|
||||
},
|
||||
|
|
@ -506,6 +508,28 @@
|
|||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/esrecurse": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
|
|
@ -533,6 +557,21 @@
|
|||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/whatwg-mimetype": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz",
|
||||
|
|
@ -780,6 +819,121 @@
|
|||
"vue": "^3.2.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
|
||||
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
|
||||
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.10",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker/node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
|
||||
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
|
||||
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.10",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
|
||||
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
|
||||
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
|
||||
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@volar/language-core": {
|
||||
"version": "2.4.28",
|
||||
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
|
||||
|
|
@ -1013,6 +1167,15 @@
|
|||
"integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
|
|
@ -1052,6 +1215,33 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-image-size": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
|
||||
"integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
|
@ -1126,6 +1316,12 @@
|
|||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
|
||||
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
|
|
@ -1387,6 +1583,15 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
|
|
@ -1550,6 +1755,24 @@
|
|||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/happy-dom": {
|
||||
"version": "20.11.2",
|
||||
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.2.tgz",
|
||||
"integrity": "sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": ">=20.0.0",
|
||||
"@types/whatwg-mimetype": "^3.0.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"buffer-image-size": "^0.6.4",
|
||||
"entities": "^7.0.1",
|
||||
"whatwg-mimetype": "^3.0.0",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
|
|
@ -2021,6 +2244,19 @@
|
|||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
|
||||
"integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
|
|
@ -2092,6 +2328,12 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
|
@ -2312,6 +2554,12 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
|
@ -2320,6 +2568,18 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
|
||||
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/synckit": {
|
||||
"version": "0.11.13",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz",
|
||||
|
|
@ -2335,6 +2595,21 @@
|
|||
"url": "https://opencollective.com/synckit"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
|
||||
"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
|
|
@ -2351,6 +2626,15 @@
|
|||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
|
||||
"integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
|
@ -2521,6 +2805,95 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
|
||||
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.10",
|
||||
"@vitest/mocker": "4.1.10",
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/runner": "4.1.10",
|
||||
"@vitest/snapshot": "4.1.10",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.10",
|
||||
"@vitest/browser-preview": "4.1.10",
|
||||
"@vitest/browser-webdriverio": "4.1.10",
|
||||
"@vitest/coverage-istanbul": "4.1.10",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"@vitest/ui": "4.1.10",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
||||
|
|
@ -2598,6 +2971,15 @@
|
|||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
|
@ -2613,6 +2995,22 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
|
@ -2622,6 +3020,27 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src",
|
||||
"test": "vitest run",
|
||||
"format": "prettier --write src",
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json"
|
||||
},
|
||||
|
|
@ -22,10 +23,12 @@
|
|||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-plugin-vue": "^10.10.0",
|
||||
"happy-dom": "^20.11.2",
|
||||
"jiti": "^2.7.0",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0",
|
||||
"vitest": "^4.1.10",
|
||||
"vue-tsc": "^3.3.8"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
|
|
@ -1,29 +1,52 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { messages } from '../lib/messages'
|
||||
import type { AppMode, AuthState } from '../lib/models'
|
||||
import AuthStatus from './AuthStatus.vue'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
auth: AuthState
|
||||
mode: AppMode | null
|
||||
healthFailed: boolean
|
||||
}>()
|
||||
defineEmits<{ logout: []; toggleDev: [] }>()
|
||||
const emit = defineEmits<{ logout: []; toggleDev: [opener: HTMLElement] }>()
|
||||
|
||||
function openDevPanel(event: MouseEvent): void {
|
||||
if (event.currentTarget instanceof HTMLElement) {
|
||||
emit('toggleDev', event.currentTarget)
|
||||
}
|
||||
}
|
||||
|
||||
const modeLabel = computed(() => {
|
||||
if (props.mode === 'demo') return messages.appHeaderDemoMode
|
||||
if (props.mode === 'live') return messages.appHeaderLiveMode
|
||||
if (props.healthFailed) return messages.appHeaderModeUnavailable
|
||||
return messages.appHeaderCheckingMode
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="header">
|
||||
<div class="inner">
|
||||
<div class="brand">
|
||||
<span class="wordmark">discovery<span class="accent">-by-</span>llm</span>
|
||||
<span class="kicker">proof of concept</span>
|
||||
<span class="wordmark"
|
||||
>{{ messages.appHeaderBrandPrefix
|
||||
}}<span class="accent">{{ messages.appHeaderBrandAccent }}</span
|
||||
>{{ messages.appHeaderBrandSuffix }}</span
|
||||
>
|
||||
<span class="kicker">{{ messages.appHeaderKicker }}</span>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<span class="mode" :class="{ failed: healthFailed }">
|
||||
{{ mode ? `${mode} mode` : healthFailed ? 'mode unavailable' : 'checking mode' }}
|
||||
{{ modeLabel }}
|
||||
</span>
|
||||
<AuthStatus :auth="auth" @logout="$emit('logout')" />
|
||||
<button class="button" type="button" @click="$emit('toggleDev')">
|
||||
Dev panel <span class="key">Ctrl+D</span>
|
||||
<button class="button" type="button" data-dev-panel-opener @click="openDevPanel">
|
||||
<span class="full-label">
|
||||
{{ messages.appHeaderDevPanel }}
|
||||
<span class="key">{{ messages.appHeaderDevPanelShortcut }}</span>
|
||||
</span>
|
||||
<span class="short-label">{{ messages.appHeaderDevPanelShort }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -116,6 +139,10 @@ defineEmits<{ logout: []; toggleDev: [] }>()
|
|||
color: var(--c-text-ghost);
|
||||
}
|
||||
|
||||
.short-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.kicker,
|
||||
.mode,
|
||||
|
|
@ -132,8 +159,12 @@ defineEmits<{ logout: []; toggleDev: [] }>()
|
|||
font-size: 0;
|
||||
}
|
||||
|
||||
.button::after {
|
||||
content: 'Dev';
|
||||
.full-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.short-label {
|
||||
display: inline;
|
||||
font-size: var(--t-micro);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatMessage, messages } from '../lib/messages'
|
||||
import type { AssistantTurn } from '../lib/models'
|
||||
import EmptyResults from './EmptyResults.vue'
|
||||
import PlaylistError from './PlaylistError.vue'
|
||||
|
|
@ -15,13 +16,16 @@ const props = defineProps<{ turn: AssistantTurn; canSave: boolean }>()
|
|||
defineEmits<{ save: []; retry: [query: string] }>()
|
||||
|
||||
const thinkingLabel = computed(() => {
|
||||
if (props.turn.requestId === null) return 'Reading the request'
|
||||
if (props.turn.requestId === null) return messages.assistantMessageReadingRequest
|
||||
if (props.turn.tracks.length === 0) {
|
||||
return props.turn.candidateCount === null
|
||||
? 'Checking candidates'
|
||||
: `Checking ${props.turn.candidateCount} candidates`
|
||||
if (props.turn.candidateCount === null) {
|
||||
return messages.assistantMessageCheckingCandidates
|
||||
}
|
||||
return formatMessage('assistantMessageCheckingCandidateCount', {
|
||||
count: props.turn.candidateCount,
|
||||
})
|
||||
}
|
||||
return 'Streaming verified tracks'
|
||||
return messages.assistantMessageStreamingTracks
|
||||
})
|
||||
|
||||
const isEmptyResult = computed(
|
||||
|
|
@ -34,7 +38,7 @@ const isEmptyResult = computed(
|
|||
|
||||
<template>
|
||||
<div class="assistant">
|
||||
<div class="avatar" aria-hidden="true">d</div>
|
||||
<div class="avatar" aria-hidden="true">{{ messages.assistantMessageAvatar }}</div>
|
||||
<div class="column">
|
||||
<p v-if="turn.intentSummary" class="intent">{{ turn.intentSummary }}</p>
|
||||
|
||||
|
|
@ -76,7 +80,7 @@ const isEmptyResult = computed(
|
|||
:message="turn.playlist.message"
|
||||
/>
|
||||
<PlaylistSaved
|
||||
v-if="turn.playlist.status === 'saved' && turn.playlist.name && turn.playlist.url"
|
||||
v-if="turn.playlist.status === 'saved' && turn.playlist.name"
|
||||
:name="turn.playlist.name"
|
||||
:url="turn.playlist.url"
|
||||
:track-count="turn.tracks.length"
|
||||
|
|
|
|||
|
|
@ -1,29 +1,39 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { messages } from '../lib/messages'
|
||||
import type { AuthState } from '../lib/models'
|
||||
|
||||
defineProps<{ auth: AuthState }>()
|
||||
const props = defineProps<{ auth: AuthState }>()
|
||||
defineEmits<{ logout: [] }>()
|
||||
|
||||
const logoutLabel = computed(() => {
|
||||
if (props.auth.status === 'logging_out') return messages.authStatusLoggingOut
|
||||
return messages.authStatusLogOut
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="auth.status === 'checking'"
|
||||
class="checking"
|
||||
aria-label="Checking Spotify connection"
|
||||
:aria-label="messages.authStatusCheckingConnection"
|
||||
/>
|
||||
<a v-else-if="auth.status === 'anonymous'" class="button" href="/api/auth/login">
|
||||
<span class="full-label">Connect Spotify</span><span class="short-label">Connect</span>
|
||||
<span class="full-label">{{ messages.authStatusConnectSpotify }}</span
|
||||
><span class="short-label">{{ messages.authStatusConnect }}</span>
|
||||
</a>
|
||||
<span v-else-if="auth.status === 'failed'" class="failed" role="alert">
|
||||
<span class="failure-message">{{ auth.message }}</span>
|
||||
<a class="button" href="/api/auth/login">
|
||||
<span class="full-label">Try Spotify again</span><span class="short-label">Retry</span>
|
||||
<span class="full-label">{{ messages.authStatusTryAgain }}</span
|
||||
><span class="short-label">{{ messages.authStatusRetry }}</span>
|
||||
</a>
|
||||
</span>
|
||||
<span v-else class="authenticated">
|
||||
<span class="user">
|
||||
<span class="dot" aria-hidden="true" />
|
||||
<span class="connected">Connected as </span>{{ auth.user.display_name }}
|
||||
<span class="connected">{{ messages.authStatusConnectedAs }}</span
|
||||
>{{ auth.user.display_name }}
|
||||
</span>
|
||||
<button
|
||||
class="button"
|
||||
|
|
@ -31,7 +41,7 @@ defineEmits<{ logout: [] }>()
|
|||
:disabled="auth.status === 'logging_out'"
|
||||
@click="$emit('logout')"
|
||||
>
|
||||
{{ auth.status === 'logging_out' ? 'Logging out...' : 'Log out' }}
|
||||
{{ logoutLabel }}
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,16 @@
|
|||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useApi } from '../composables/useApi'
|
||||
import { useChatStream } from '../composables/useChatStream'
|
||||
import {
|
||||
FAST_TRANSITION_DURATION_MS,
|
||||
MESSAGE_ENTRANCE_ANIMATION_DURATION_MS,
|
||||
PLAYLIST_SAVED_ANIMATION_DURATION_MS,
|
||||
REDUCED_MOTION_DURATION_MS,
|
||||
THINKING_PULSE_ANIMATION_DURATION_MS,
|
||||
THINKING_PULSE_STAGGER_MS,
|
||||
TRACK_CARD_ANIMATION_DURATION_MS,
|
||||
} from '../lib/constants'
|
||||
import { formatMessage, messages } from '../lib/messages'
|
||||
import { DISCOVERY_SUGGESTIONS } from '../lib/suggestions'
|
||||
import AppHeader from './AppHeader.vue'
|
||||
import DevPanel from './DevPanel.vue'
|
||||
|
|
@ -11,16 +21,32 @@ import ModeBanner from './ModeBanner.vue'
|
|||
|
||||
const draft = ref('')
|
||||
const devOpen = ref(false)
|
||||
const devOpener = ref<HTMLElement | null>(null)
|
||||
const input = ref<InstanceType<typeof MessageInput> | null>(null)
|
||||
const { auth, health, bootstrap, logout, createPlaylist } = useApi()
|
||||
const { turns, eventLog, isStreaming, turnCount, latestAssistant, send, savePlaylist, reset } =
|
||||
useChatStream(createPlaylist)
|
||||
|
||||
const mode = computed(() => (health.value.status === 'ready' ? health.value.mode : null))
|
||||
const timingStyles: Record<string, string> = {
|
||||
'--dur-fast': `${FAST_TRANSITION_DURATION_MS}ms`,
|
||||
'--dur-card': `${TRACK_CARD_ANIMATION_DURATION_MS}ms`,
|
||||
'--dur-message': `${MESSAGE_ENTRANCE_ANIMATION_DURATION_MS}ms`,
|
||||
'--dur-playlist-saved': `${PLAYLIST_SAVED_ANIMATION_DURATION_MS}ms`,
|
||||
'--dur-thinking-pulse': `${THINKING_PULSE_ANIMATION_DURATION_MS}ms`,
|
||||
'--delay-thinking-pulse': `${THINKING_PULSE_STAGGER_MS}ms`,
|
||||
'--dur-reduced-motion': `${REDUCED_MOTION_DURATION_MS}ms`,
|
||||
}
|
||||
const mode = computed(() => {
|
||||
if (health.value.status === 'ready') return health.value.mode
|
||||
return null
|
||||
})
|
||||
const canSave = computed(() => auth.value.status === 'authenticated')
|
||||
const turnCounter = computed(() => {
|
||||
if (turnCount.value === 0) return 'no turns yet'
|
||||
return `${turnCount.value} ${turnCount.value === 1 ? 'turn' : 'turns'} in this session`
|
||||
if (turnCount.value === 0) return messages.chatViewNoTurns
|
||||
if (turnCount.value === 1) {
|
||||
return formatMessage('chatViewOneTurn', { count: turnCount.value })
|
||||
}
|
||||
return formatMessage('chatViewManyTurns', { count: turnCount.value })
|
||||
})
|
||||
|
||||
async function focusInput(): Promise<void> {
|
||||
|
|
@ -44,10 +70,29 @@ function submit(query: string): void {
|
|||
void send(query)
|
||||
}
|
||||
|
||||
function openDevPanel(opener?: HTMLElement): void {
|
||||
if (opener) {
|
||||
devOpener.value = opener
|
||||
devOpen.value = true
|
||||
return
|
||||
}
|
||||
const activeElement = document.activeElement
|
||||
if (activeElement instanceof HTMLElement) {
|
||||
devOpener.value = activeElement
|
||||
} else {
|
||||
devOpener.value = null
|
||||
}
|
||||
devOpen.value = true
|
||||
}
|
||||
|
||||
function onShortcut(event: KeyboardEvent): void {
|
||||
if (event.ctrlKey && event.key.toLowerCase() === 'd') {
|
||||
event.preventDefault()
|
||||
devOpen.value = !devOpen.value
|
||||
if (devOpen.value) {
|
||||
devOpen.value = false
|
||||
} else {
|
||||
openDevPanel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -60,14 +105,14 @@ onUnmounted(() => window.removeEventListener('keydown', onShortcut))
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-view">
|
||||
<div class="chat-view" :style="timingStyles">
|
||||
<div class="shell" :inert="devOpen">
|
||||
<AppHeader
|
||||
:auth="auth"
|
||||
:mode="mode"
|
||||
:health-failed="health.status === 'failed'"
|
||||
@logout="logout"
|
||||
@toggle-dev="devOpen = true"
|
||||
@toggle-dev="openDevPanel"
|
||||
/>
|
||||
|
||||
<ModeBanner v-if="mode === 'demo'" />
|
||||
|
|
@ -94,6 +139,7 @@ onUnmounted(() => window.removeEventListener('keydown', onShortcut))
|
|||
v-if="devOpen"
|
||||
:latest="latestAssistant"
|
||||
:log="eventLog"
|
||||
:opener="devOpener"
|
||||
@close="devOpen = false"
|
||||
@reset="reset"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,40 +1,49 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { messages } from '../lib/messages'
|
||||
import type { AssistantTurn, EventLogEntry } from '../lib/models'
|
||||
|
||||
const props = defineProps<{
|
||||
latest: AssistantTurn | undefined
|
||||
log: EventLogEntry[]
|
||||
opener: HTMLElement | null
|
||||
}>()
|
||||
const emit = defineEmits<{ close: []; reset: [] }>()
|
||||
|
||||
const panel = ref<HTMLElement | null>(null)
|
||||
const closeButton = ref<HTMLButtonElement | null>(null)
|
||||
let returnFocus: HTMLElement | null = null
|
||||
|
||||
function latestValue(value: string | null | undefined): string {
|
||||
return value ?? messages.devPanelWaiting
|
||||
}
|
||||
|
||||
const rows = computed(() => [
|
||||
{ label: 'request id', value: props.latest?.requestId ?? 'waiting' },
|
||||
{ label: messages.devPanelRequestId, value: latestValue(props.latest?.requestId) },
|
||||
{
|
||||
label: 'candidate count',
|
||||
value: props.latest?.candidateCount?.toString() ?? 'waiting',
|
||||
label: messages.devPanelCandidateCount,
|
||||
value: latestValue(props.latest?.candidateCount?.toString()),
|
||||
},
|
||||
{
|
||||
label: 'track count',
|
||||
value:
|
||||
props.latest?.completion?.track_count.toString() ??
|
||||
props.latest?.tracks.length.toString() ??
|
||||
'waiting',
|
||||
label: messages.devPanelTrackCount,
|
||||
value: latestValue(
|
||||
props.latest?.completion?.track_count.toString() ?? props.latest?.tracks.length.toString(),
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'total ms',
|
||||
value: props.latest?.completion?.total_ms.toString() ?? 'waiting',
|
||||
label: messages.devPanelTotalMilliseconds,
|
||||
value: latestValue(props.latest?.completion?.total_ms.toString()),
|
||||
},
|
||||
])
|
||||
const entries = computed(() =>
|
||||
props.log.length
|
||||
? [...props.log].reverse()
|
||||
: [{ timestamp: '--:--:--', type: 'idle', detail: 'waiting for a request' }],
|
||||
)
|
||||
const entries = computed(() => {
|
||||
if (props.log.length) return [...props.log].reverse()
|
||||
return [
|
||||
{
|
||||
timestamp: '--:--:--',
|
||||
type: messages.devPanelIdle,
|
||||
detail: messages.devPanelWaitingForRequest,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
function focusableElements(): HTMLElement[] {
|
||||
if (!panel.value) return []
|
||||
|
|
@ -67,12 +76,15 @@ function onKeydown(event: KeyboardEvent): void {
|
|||
}
|
||||
|
||||
onMounted(async () => {
|
||||
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
await nextTick()
|
||||
closeButton.value?.focus()
|
||||
})
|
||||
|
||||
onUnmounted(() => returnFocus?.focus())
|
||||
onUnmounted(() => {
|
||||
const fallback = document.querySelector<HTMLElement>('[data-dev-panel-opener]')
|
||||
const target = props.opener?.isConnected ? props.opener : fallback
|
||||
target?.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -86,21 +98,21 @@ onUnmounted(() => returnFocus?.focus())
|
|||
@keydown="onKeydown"
|
||||
>
|
||||
<div class="bar">
|
||||
<h2 id="dev-panel-title" class="caps">Dev panel</h2>
|
||||
<h2 id="dev-panel-title" class="caps">{{ messages.devPanelTitle }}</h2>
|
||||
<button
|
||||
ref="closeButton"
|
||||
class="close"
|
||||
type="button"
|
||||
aria-label="Close dev panel"
|
||||
:aria-label="messages.devPanelCloseLabel"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<span aria-hidden="true">X</span>
|
||||
<span aria-hidden="true">{{ messages.devPanelCloseSymbol }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="body">
|
||||
<section>
|
||||
<h3 class="caps">Latest request</h3>
|
||||
<p class="caption">Only counters supplied by the recommendation contract are shown.</p>
|
||||
<h3 class="caps">{{ messages.devPanelLatestRequest }}</h3>
|
||||
<p class="caption">{{ messages.devPanelContractCaption }}</p>
|
||||
<div class="table">
|
||||
<div v-for="row in rows" :key="row.label" class="row">
|
||||
<span class="key">{{ row.label }}</span
|
||||
|
|
@ -109,11 +121,13 @@ onUnmounted(() => returnFocus?.focus())
|
|||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h3 class="caps section-heading">Conversation</h3>
|
||||
<button class="reset" type="button" @click="$emit('reset')">Clear conversation</button>
|
||||
<h3 class="caps section-heading">{{ messages.devPanelConversation }}</h3>
|
||||
<button class="reset" type="button" @click="$emit('reset')">
|
||||
{{ messages.devPanelClearConversation }}
|
||||
</button>
|
||||
</section>
|
||||
<section>
|
||||
<h3 class="caps section-heading">Event stream</h3>
|
||||
<h3 class="caps section-heading">{{ messages.devPanelEventStream }}</h3>
|
||||
<div class="log">
|
||||
<div v-for="(entry, index) in entries" :key="index" class="entry">
|
||||
<span class="time">{{ entry.timestamp }}</span>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { messages } from '../lib/messages'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty-results">
|
||||
<strong>No verified tracks matched this request.</strong>
|
||||
<span>Try broadening the moment or removing one constraint.</span>
|
||||
<strong>{{ messages.emptyResultsTitle }}</strong>
|
||||
<span>{{ messages.emptyResultsGuidance }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { messages } from '../lib/messages'
|
||||
import SuggestionChips from './SuggestionChips.vue'
|
||||
|
||||
defineProps<{ suggestions: readonly string[] }>()
|
||||
|
|
@ -7,13 +8,15 @@ defineEmits<{ pick: [suggestion: string] }>()
|
|||
|
||||
<template>
|
||||
<div class="empty">
|
||||
<h1>What should be playing<span class="accent"> right now?</span></h1>
|
||||
<h1>
|
||||
{{ messages.emptyStateHeading
|
||||
}}<span class="accent">{{ messages.emptyStateHeadingAccent }}</span>
|
||||
</h1>
|
||||
<p class="lede">
|
||||
Describe the moment, not the genre. The assistant reads the request, proposes candidates,
|
||||
verifies each one on Spotify and explains why it picked them.
|
||||
{{ messages.emptyStateDescription }}
|
||||
</p>
|
||||
<template v-if="suggestions.length">
|
||||
<div class="label">Try one of these</div>
|
||||
<div class="label">{{ messages.emptyStateSuggestionLabel }}</div>
|
||||
<SuggestionChips :suggestions="suggestions" @pick="$emit('pick', $event)" />
|
||||
</template>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { NARROW_COMPOSER_MAX_WIDTH_PX, QUERY_MAX_LENGTH } from '../lib/constants'
|
||||
import { messages } from '../lib/messages'
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
const props = defineProps<{ disabled: boolean; turnCounter: string }>()
|
||||
|
|
@ -7,10 +9,17 @@ const emit = defineEmits<{ send: [query: string] }>()
|
|||
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
const narrow = ref(false)
|
||||
const placeholder = 'Describe the moment: "something calm while I am programming, but not boring"'
|
||||
let mediaQuery: MediaQueryList | null = null
|
||||
|
||||
const sendDisabled = computed(() => props.disabled || model.value.trim().length === 0)
|
||||
const placeholder = computed(() => {
|
||||
if (narrow.value) return messages.messageInputNarrowPlaceholder
|
||||
return messages.messageInputPlaceholder
|
||||
})
|
||||
const buttonLabel = computed(() => {
|
||||
if (props.disabled) return messages.messageInputWorking
|
||||
return messages.messageInputSend
|
||||
})
|
||||
|
||||
function updateWidth(event?: MediaQueryListEvent): void {
|
||||
narrow.value = event?.matches ?? mediaQuery?.matches ?? false
|
||||
|
|
@ -27,7 +36,7 @@ function focus(): void {
|
|||
}
|
||||
|
||||
onMounted(() => {
|
||||
mediaQuery = window.matchMedia('(max-width: 559px)')
|
||||
mediaQuery = window.matchMedia(`(max-width: ${NARROW_COMPOSER_MAX_WIDTH_PX}px)`)
|
||||
updateWidth()
|
||||
mediaQuery.addEventListener('change', updateWidth)
|
||||
})
|
||||
|
|
@ -43,17 +52,18 @@ defineExpose({ focus })
|
|||
<input
|
||||
ref="input"
|
||||
v-model="model"
|
||||
aria-label="Describe what you want to listen to"
|
||||
:placeholder="narrow ? 'Describe the moment...' : placeholder"
|
||||
:aria-label="messages.messageInputAriaLabel"
|
||||
:maxlength="QUERY_MAX_LENGTH"
|
||||
:placeholder="placeholder"
|
||||
@keydown.enter.prevent="submit($event)"
|
||||
/>
|
||||
<button class="send" type="button" :disabled="sendDisabled" @click="submit()">
|
||||
{{ disabled ? 'Working...' : 'Send' }}
|
||||
{{ buttonLabel }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="hints">
|
||||
<span class="hint">
|
||||
Enter to send / refine with follow-ups like "more electronic and drop number 3"
|
||||
{{ messages.messageInputHint }}
|
||||
</span>
|
||||
<span>{{ turnCounter }}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { MESSAGE_LIST_PIN_THRESHOLD_PX } from '../lib/constants'
|
||||
import { formatMessage, messages } from '../lib/messages'
|
||||
import type { AssistantTurn, ChatTurn } from '../lib/models'
|
||||
import AssistantMessage from './AssistantMessage.vue'
|
||||
import EmptyState from './EmptyState.vue'
|
||||
|
|
@ -18,37 +20,49 @@ defineEmits<{
|
|||
|
||||
const list = ref<HTMLElement | null>(null)
|
||||
const pinned = ref(true)
|
||||
const fingerprint = computed(() =>
|
||||
props.turns
|
||||
.map((turn) =>
|
||||
turn.role === 'user'
|
||||
? turn.id
|
||||
: `${turn.id}:${turn.tracks.length}:${turn.warnings.length}:${turn.status}`,
|
||||
)
|
||||
.join('|'),
|
||||
)
|
||||
function turnFingerprint(turn: ChatTurn): string {
|
||||
if (turn.role === 'user') return turn.id
|
||||
return `${turn.id}:${turn.tracks.length}:${turn.warnings.length}:${turn.status}`
|
||||
}
|
||||
|
||||
function findLatestAssistant(): AssistantTurn | undefined {
|
||||
for (let index = props.turns.length - 1; index >= 0; index -= 1) {
|
||||
const turn = props.turns[index]
|
||||
if (turn?.role === 'assistant') return turn
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const fingerprint = computed(() => props.turns.map(turnFingerprint).join('|'))
|
||||
const streaming = computed(() =>
|
||||
props.turns.some((turn) => turn.role === 'assistant' && turn.status === 'streaming'),
|
||||
)
|
||||
const latestAssistant = computed(() =>
|
||||
[...props.turns].reverse().find((turn): turn is AssistantTurn => turn.role === 'assistant'),
|
||||
)
|
||||
const latestAssistant = computed(findLatestAssistant)
|
||||
const announcement = computed(() => {
|
||||
const turn = latestAssistant.value
|
||||
if (!turn) return ''
|
||||
if (turn.status === 'error') return 'The recommendation request failed.'
|
||||
if (turn.status === 'error') return messages.messageListRequestFailed
|
||||
if (turn.status === 'done') {
|
||||
return `${turn.completion?.track_count ?? turn.tracks.length} tracks ready.`
|
||||
const trackCount = turn.completion?.track_count ?? turn.tracks.length
|
||||
return formatMessage('messageListTracksReady', { count: trackCount })
|
||||
}
|
||||
if (turn.requestId === null) return 'Reading the request.'
|
||||
if (turn.tracks.length === 0) return 'Checking recommendation candidates.'
|
||||
return `${turn.tracks.length} verified tracks received.`
|
||||
const warning = turn.warnings.at(-1)
|
||||
if (warning) {
|
||||
return formatMessage('messageListWarning', {
|
||||
count: turn.warnings.length,
|
||||
message: warning.message,
|
||||
})
|
||||
}
|
||||
if (turn.requestId === null) return messages.messageListReadingRequest
|
||||
if (turn.tracks.length === 0) return messages.messageListCheckingCandidates
|
||||
return formatMessage('messageListVerifiedTracksReceived', { count: turn.tracks.length })
|
||||
})
|
||||
|
||||
function onScroll(): void {
|
||||
const element = list.value
|
||||
if (!element) return
|
||||
pinned.value = element.scrollHeight - element.scrollTop - element.clientHeight < 48
|
||||
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight
|
||||
pinned.value = distanceFromBottom < MESSAGE_LIST_PIN_THRESHOLD_PX
|
||||
}
|
||||
|
||||
async function scrollToLatest(): Promise<void> {
|
||||
|
|
@ -66,7 +80,12 @@ watch(streaming, (value) => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<main ref="list" class="list" aria-label="Conversation" @scroll="onScroll">
|
||||
<main
|
||||
ref="list"
|
||||
class="list"
|
||||
:aria-label="messages.messageListConversationLabel"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<p class="visually-hidden" aria-live="polite" aria-atomic="true">
|
||||
{{ announcement }}
|
||||
</p>
|
||||
|
|
@ -107,7 +126,7 @@ watch(streaming, (value) => {
|
|||
}
|
||||
|
||||
.turn {
|
||||
animation: message-in 0.3s ease both;
|
||||
animation: message-in var(--dur-message) ease both;
|
||||
}
|
||||
|
||||
@keyframes message-in {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import { messages } from '../lib/messages'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="banner" role="status">
|
||||
<div class="inner">
|
||||
<span class="dot" aria-hidden="true" />
|
||||
<span>Demo mode: replaying recorded recommendation sessions</span>
|
||||
<span>{{ messages.modeBannerDemo }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{ name: string; url: string; trackCount: number }>()
|
||||
import { computed } from 'vue'
|
||||
import { formatMessage, messages } from '../lib/messages'
|
||||
|
||||
const props = defineProps<{ name: string; url: string | null; trackCount: number }>()
|
||||
const metadata = computed(() => formatMessage('playlistSavedMetadata', { count: props.trackCount }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="saved" role="status">
|
||||
<div>
|
||||
<div class="line">
|
||||
Saved <span class="name">{{ name }}</span>
|
||||
{{ messages.playlistSavedConfirmationPrefix }}<span class="name">{{ name }}</span>
|
||||
</div>
|
||||
<div class="meta">{{ trackCount }} tracks / private / created just now</div>
|
||||
<div class="meta">{{ metadata }}</div>
|
||||
</div>
|
||||
<a class="link" :href="url" target="_blank" rel="noreferrer">
|
||||
Open in Spotify <span aria-hidden="true">↗</span>
|
||||
<a v-if="url" class="link" :href="url" target="_blank" rel="noreferrer">
|
||||
{{ messages.playlistSavedOpenSpotify }} <span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -27,7 +31,7 @@ defineProps<{ name: string; url: string; trackCount: number }>()
|
|||
background: var(--c-saved-bg);
|
||||
border: 1px solid var(--c-saved-line);
|
||||
border-radius: var(--r-md);
|
||||
animation: save-pop 0.26s ease both;
|
||||
animation: save-pop var(--dur-playlist-saved) ease both;
|
||||
}
|
||||
|
||||
.line {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatMessage } from '../lib/messages'
|
||||
import type { AssistantTurn } from '../lib/models'
|
||||
|
||||
const props = defineProps<{ turn: AssistantTurn }>()
|
||||
const parts = computed(() => {
|
||||
const values: string[] = []
|
||||
if (props.turn.candidateCount !== null) {
|
||||
values.push(`${props.turn.candidateCount} candidates considered`)
|
||||
values.push(formatMessage('requestMetadataCandidates', { count: props.turn.candidateCount }))
|
||||
}
|
||||
if (props.turn.completion) {
|
||||
values.push(`${props.turn.completion.track_count} verified`)
|
||||
values.push(`${props.turn.completion.total_ms} ms`)
|
||||
values.push(
|
||||
formatMessage('requestMetadataVerified', { count: props.turn.completion.track_count }),
|
||||
)
|
||||
values.push(
|
||||
formatMessage('requestMetadataDuration', {
|
||||
milliseconds: props.turn.completion.total_ms,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return values
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatMessage, messages } from '../lib/messages'
|
||||
import type { PlaylistState } from '../lib/models'
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -10,10 +11,11 @@ const props = defineProps<{
|
|||
defineEmits<{ save: [] }>()
|
||||
|
||||
const buttonLabel = computed(() => {
|
||||
if (props.state.status === 'saving') return 'Saving...'
|
||||
if (props.state.status === 'error') return 'Try saving again'
|
||||
return 'Save as playlist'
|
||||
if (props.state.status === 'saving') return messages.resultActionsSaving
|
||||
if (props.state.status === 'error') return messages.resultActionsTryAgain
|
||||
return messages.resultActionsSavePlaylist
|
||||
})
|
||||
const summary = computed(() => formatMessage('resultActionsSummary', { count: props.trackCount }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -27,8 +29,8 @@ const buttonLabel = computed(() => {
|
|||
>
|
||||
{{ buttonLabel }}
|
||||
</button>
|
||||
<a v-else class="connect" href="/api/auth/login">Connect Spotify to save</a>
|
||||
<span class="summary">{{ trackCount }} verified tracks ready for a private playlist</span>
|
||||
<a v-else class="connect" href="/api/auth/login">{{ messages.resultActionsConnectSpotify }}</a>
|
||||
<span class="summary">{{ summary }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { messages } from '../lib/messages'
|
||||
import type { TrackEvent } from '../lib/types'
|
||||
import TrackCard from './TrackCard.vue'
|
||||
|
||||
|
|
@ -6,7 +7,7 @@ defineProps<{ tracks: TrackEvent[] }>()
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="set" aria-label="Recommended tracks">
|
||||
<div class="set" :aria-label="messages.resultSetRecommendedTracksLabel">
|
||||
<TrackCard v-for="event in tracks" :key="event.track.id" :event="event" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { messages } from '../lib/messages'
|
||||
|
||||
defineProps<{ code: string; message: string }>()
|
||||
defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
|
|
@ -6,7 +8,7 @@ defineEmits<{ retry: [] }>()
|
|||
<template>
|
||||
<div class="error" role="alert" :data-error-code="code">
|
||||
<p>{{ message }}</p>
|
||||
<button type="button" @click="$emit('retry')">Edit and retry</button>
|
||||
<button type="button" @click="$emit('retry')">{{ messages.streamErrorRetry }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -26,15 +26,15 @@ defineProps<{ label: string }>()
|
|||
height: 5px;
|
||||
background: var(--c-accent);
|
||||
border-radius: 50%;
|
||||
animation: pulse-dot 1.1s infinite;
|
||||
animation: pulse-dot var(--dur-thinking-pulse) infinite;
|
||||
}
|
||||
|
||||
.dot:nth-child(2) {
|
||||
animation-delay: 0.18s;
|
||||
animation-delay: var(--delay-thinking-pulse);
|
||||
}
|
||||
|
||||
.dot:nth-child(3) {
|
||||
animation-delay: 0.36s;
|
||||
animation-delay: calc(2 * var(--delay-thinking-pulse));
|
||||
}
|
||||
|
||||
.label {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { formatMessage, messages } from '../lib/messages'
|
||||
import type { TrackEvent } from '../lib/types'
|
||||
|
||||
const props = defineProps<{ event: TrackEvent }>()
|
||||
const artworkFailed = ref(false)
|
||||
const artists = computed(() => props.event.track.artists.join(', '))
|
||||
const spotifyUrl = computed(() => props.event.track.external_url)
|
||||
const artworkAlt = computed(() =>
|
||||
formatMessage('trackCardArtworkAlt', { album: props.event.track.album_name }),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -14,7 +19,7 @@ const artists = computed(() => props.event.track.artists.join(', '))
|
|||
<img
|
||||
v-if="event.track.album_art_url && !artworkFailed"
|
||||
:src="event.track.album_art_url"
|
||||
:alt="`${event.track.album_name} album artwork`"
|
||||
:alt="artworkAlt"
|
||||
@error="artworkFailed = true"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -26,14 +31,8 @@ const artists = computed(() => props.event.track.artists.join(', '))
|
|||
<div class="album">{{ event.track.album_name }}</div>
|
||||
<div class="why">{{ event.justification }}</div>
|
||||
</div>
|
||||
<a
|
||||
v-if="event.track.external_url"
|
||||
class="link"
|
||||
:href="event.track.external_url"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Open in Spotify
|
||||
<a v-if="spotifyUrl" class="link" :href="spotifyUrl" target="_blank" rel="noreferrer">
|
||||
{{ messages.trackCardOpenSpotify }}
|
||||
<span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { ref } from 'vue'
|
||||
import { formatMessage, messages } from '../lib/messages'
|
||||
import type { AuthState, HealthState } from '../lib/models'
|
||||
import type { CurrentUser, PlaylistCreateRequest, PlaylistCreateResponse } from '../lib/types'
|
||||
import { parseSpotifyUrl } from '../lib/spotifyUrl'
|
||||
import type { CurrentUser, PlaylistCreateRequest } from '../lib/types'
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
|
|
@ -8,16 +10,16 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||
|
||||
function parseCurrentUser(value: unknown): CurrentUser {
|
||||
if (!isRecord(value) || typeof value.display_name !== 'string') {
|
||||
throw new Error('Invalid current user response.')
|
||||
throw new Error(messages.useApiInvalidCurrentUser)
|
||||
}
|
||||
return { display_name: value.display_name }
|
||||
}
|
||||
|
||||
function parsePlaylistResponse(value: unknown): PlaylistCreateResponse {
|
||||
function parsePlaylistResponse(value: unknown): { url: string | null } {
|
||||
if (!isRecord(value) || typeof value.url !== 'string') {
|
||||
throw new Error('Invalid playlist response.')
|
||||
throw new Error(messages.useApiInvalidPlaylist)
|
||||
}
|
||||
return { url: value.url }
|
||||
return { url: parseSpotifyUrl(value.url) }
|
||||
}
|
||||
|
||||
function loginFailed(): boolean {
|
||||
|
|
@ -39,28 +41,30 @@ export function useApi() {
|
|||
try {
|
||||
const response = await fetch('/api/auth/me', { credentials: 'same-origin' })
|
||||
if (response.status === 401) {
|
||||
auth.value = hadLoginError
|
||||
? {
|
||||
status: 'failed',
|
||||
user: null,
|
||||
message: 'Spotify login did not complete. Please try again.',
|
||||
}
|
||||
: { status: 'anonymous', user: null, message: null }
|
||||
if (hadLoginError) {
|
||||
auth.value = {
|
||||
status: 'failed',
|
||||
user: null,
|
||||
message: messages.useApiLoginFailed,
|
||||
}
|
||||
} else {
|
||||
auth.value = { status: 'anonymous', user: null, message: null }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!response.ok) throw new Error('Authentication check failed.')
|
||||
if (!response.ok) throw new Error(messages.useApiAuthenticationCheckFailed)
|
||||
auth.value = {
|
||||
status: 'authenticated',
|
||||
user: parseCurrentUser(await response.json()),
|
||||
message: null,
|
||||
}
|
||||
} catch {
|
||||
let message: string = messages.useApiConnectionUnavailable
|
||||
if (hadLoginError) message = messages.useApiLoginFailed
|
||||
auth.value = {
|
||||
status: 'failed',
|
||||
user: null,
|
||||
message: hadLoginError
|
||||
? 'Spotify login did not complete. Please try again.'
|
||||
: 'Spotify connection status is unavailable.',
|
||||
message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -68,21 +72,21 @@ export function useApi() {
|
|||
async function loadHealth(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch('/api/health', { credentials: 'same-origin' })
|
||||
if (!response.ok) throw new Error('Health check failed.')
|
||||
if (!response.ok) throw new Error(messages.useApiHealthCheckFailed)
|
||||
const body: unknown = await response.json()
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
body.status !== 'ok' ||
|
||||
(body.mode !== 'demo' && body.mode !== 'live')
|
||||
) {
|
||||
throw new Error('Invalid health response.')
|
||||
throw new Error(messages.useApiInvalidHealth)
|
||||
}
|
||||
health.value = { status: 'ready', mode: body.mode, message: null }
|
||||
} catch {
|
||||
health.value = {
|
||||
status: 'failed',
|
||||
mode: null,
|
||||
message: 'Service mode is unavailable.',
|
||||
message: messages.useApiModeUnavailable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -101,25 +105,27 @@ export function useApi() {
|
|||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
if (!response.ok) throw new Error('Logout failed.')
|
||||
if (!response.ok) throw new Error(messages.useApiLogoutFailed)
|
||||
auth.value = { status: 'anonymous', user: null, message: null }
|
||||
} catch {
|
||||
auth.value = {
|
||||
status: 'failed',
|
||||
user: null,
|
||||
message: 'Spotify logout failed. Refresh before trying again.',
|
||||
message: messages.useApiSpotifyLogoutFailed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createPlaylist(request: PlaylistCreateRequest): Promise<PlaylistCreateResponse> {
|
||||
async function createPlaylist(request: PlaylistCreateRequest): Promise<{ url: string | null }> {
|
||||
const response = await fetch('/api/playlists', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
if (!response.ok) throw new Error(`Playlist creation returned ${response.status}.`)
|
||||
if (!response.ok) {
|
||||
throw new Error(formatMessage('useApiPlaylistCreationFailed', { status: response.status }))
|
||||
}
|
||||
return parsePlaylistResponse(await response.json())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import {
|
||||
EVENT_LOG_MAX_ENTRIES,
|
||||
HISTORY_CONTENT_MAX_LENGTH,
|
||||
HISTORY_MAX_TURNS,
|
||||
PLAYLIST_NAME_MAX_LENGTH,
|
||||
PRIOR_RECOMMENDATIONS_MAX_TRACKS,
|
||||
QUERY_MAX_LENGTH,
|
||||
} from '../lib/constants'
|
||||
import { formatMessage, messages } from '../lib/messages'
|
||||
import type { AssistantTurn, ChatTurn, EventLogEntry, TransportFailure } from '../lib/models'
|
||||
import { EMPTY_PLAYLIST_STATE } from '../lib/models'
|
||||
import {
|
||||
|
|
@ -9,24 +18,25 @@ import {
|
|||
import type {
|
||||
HistoryTurn,
|
||||
PlaylistCreateRequest,
|
||||
PlaylistCreateResponse,
|
||||
PriorRecommendation,
|
||||
RecommendationRequest,
|
||||
StreamEvent,
|
||||
} from '../lib/types'
|
||||
|
||||
const HISTORY_LIMIT = 12
|
||||
const PRIOR_RECOMMENDATION_LIMIT = 50
|
||||
const EVENT_LOG_LIMIT = 100
|
||||
type PlaylistCreator = (request: PlaylistCreateRequest) => Promise<{ url: string | null }>
|
||||
|
||||
type PlaylistCreator = (request: PlaylistCreateRequest) => Promise<PlaylistCreateResponse>
|
||||
interface ActiveRequest {
|
||||
controller: AbortController
|
||||
turnId: string
|
||||
terminalReceived: boolean
|
||||
}
|
||||
|
||||
function assistantContent(turn: AssistantTurn): string {
|
||||
const trackLines = turn.tracks.map(
|
||||
(event) => `${event.rank}. ${event.track.title} by ${event.track.artists.join(', ')}`,
|
||||
)
|
||||
const content = [turn.intentSummary, ...trackLines].filter(Boolean).join('\n')
|
||||
return content.slice(0, 2000)
|
||||
return content.slice(0, HISTORY_CONTENT_MAX_LENGTH)
|
||||
}
|
||||
|
||||
function buildHistory(turns: ChatTurn[]): HistoryTurn[] {
|
||||
|
|
@ -37,15 +47,21 @@ function buildHistory(turns: ChatTurn[]): HistoryTurn[] {
|
|||
return content ? { role: 'assistant', content } : null
|
||||
})
|
||||
.filter((turn): turn is HistoryTurn => turn !== null)
|
||||
.slice(-HISTORY_LIMIT)
|
||||
.slice(-HISTORY_MAX_TURNS)
|
||||
}
|
||||
|
||||
function findLatestCompletedAssistant(turns: ChatTurn[]): AssistantTurn | undefined {
|
||||
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
||||
const turn = turns[index]
|
||||
if (turn?.role === 'assistant' && turn.status === 'done') return turn
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function buildPriorRecommendations(turns: ChatTurn[]): PriorRecommendation[] {
|
||||
const latest = [...turns]
|
||||
.reverse()
|
||||
.find((turn): turn is AssistantTurn => turn.role === 'assistant' && turn.status === 'done')
|
||||
const latest = findLatestCompletedAssistant(turns)
|
||||
if (!latest) return []
|
||||
return latest.tracks.slice(0, PRIOR_RECOMMENDATION_LIMIT).map((event) => ({
|
||||
return latest.tracks.slice(0, PRIOR_RECOMMENDATIONS_MAX_TRACKS).map((event) => ({
|
||||
rank: event.rank,
|
||||
track_id: event.track.id,
|
||||
title: event.track.title,
|
||||
|
|
@ -76,7 +92,7 @@ function reduceEvent(turn: AssistantTurn, event: StreamEvent): AssistantTurn {
|
|||
completion: event,
|
||||
transportFailure: {
|
||||
kind: 'protocol',
|
||||
message: 'The final track count did not match the streamed results.',
|
||||
message: messages.useChatStreamTrackCountMismatch,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -85,64 +101,69 @@ function reduceEvent(turn: AssistantTurn, event: StreamEvent): AssistantTurn {
|
|||
}
|
||||
|
||||
function playlistName(query: string): string {
|
||||
return `[discovery-by-llm] ${query}`.slice(0, 100).trim()
|
||||
return formatMessage('useChatStreamPlaylistName', { query })
|
||||
.slice(0, PLAYLIST_NAME_MAX_LENGTH)
|
||||
.trim()
|
||||
}
|
||||
|
||||
function createTurnId(): string {
|
||||
return typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID()
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
function findLatestAssistant(turns: ChatTurn[]): AssistantTurn | undefined {
|
||||
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
||||
const turn = turns[index]
|
||||
if (turn?.role === 'assistant') return turn
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Reduce recommendation streams into conversation view state. */
|
||||
export function useChatStream(createPlaylist: PlaylistCreator) {
|
||||
const turns = ref<ChatTurn[]>([])
|
||||
const eventLog = ref<EventLogEntry[]>([])
|
||||
let activeController: AbortController | null = null
|
||||
let activeTurnId: string | null = null
|
||||
let activeRequest: ActiveRequest | null = null
|
||||
|
||||
const isStreaming = computed(() =>
|
||||
turns.value.some((turn) => turn.role === 'assistant' && turn.status === 'streaming'),
|
||||
)
|
||||
const turnCount = computed(() => turns.value.filter((turn) => turn.role === 'user').length)
|
||||
const isEmpty = computed(() => turns.value.length === 0)
|
||||
const latestAssistant = computed(() =>
|
||||
[...turns.value].reverse().find((turn): turn is AssistantTurn => turn.role === 'assistant'),
|
||||
)
|
||||
const latestAssistant = computed(() => findLatestAssistant(turns.value))
|
||||
|
||||
function updateAssistant(id: string, update: (turn: AssistantTurn) => AssistantTurn): void {
|
||||
turns.value = turns.value.map((turn) =>
|
||||
turn.role === 'assistant' && turn.id === id ? update(turn) : turn,
|
||||
)
|
||||
turns.value = turns.value.map((turn) => {
|
||||
if (turn.role === 'assistant' && turn.id === id) return update(turn)
|
||||
return turn
|
||||
})
|
||||
}
|
||||
|
||||
function addLog(type: string, detail: string): void {
|
||||
eventLog.value = [
|
||||
...eventLog.value,
|
||||
{ timestamp: new Date().toLocaleTimeString(), type, detail },
|
||||
].slice(-EVENT_LOG_LIMIT)
|
||||
].slice(-EVENT_LOG_MAX_ENTRIES)
|
||||
}
|
||||
|
||||
function cancelActive(shouldMarkTurn: boolean): void {
|
||||
if (!activeController) return
|
||||
activeController.abort()
|
||||
if (shouldMarkTurn && activeTurnId) {
|
||||
const request = activeRequest
|
||||
if (!request) return
|
||||
request.controller.abort()
|
||||
if (shouldMarkTurn && !request.terminalReceived) {
|
||||
const failure: TransportFailure = {
|
||||
kind: 'cancelled',
|
||||
message: 'This request was replaced by a newer request.',
|
||||
message: messages.useChatStreamRequestReplaced,
|
||||
}
|
||||
updateAssistant(activeTurnId, (turn) => ({
|
||||
...turn,
|
||||
status: 'error',
|
||||
transportFailure: failure,
|
||||
}))
|
||||
updateAssistant(request.turnId, (turn) => {
|
||||
if (turn.status !== 'streaming') return turn
|
||||
return { ...turn, status: 'error', transportFailure: failure }
|
||||
})
|
||||
}
|
||||
activeController = null
|
||||
activeTurnId = null
|
||||
activeRequest = null
|
||||
}
|
||||
|
||||
async function send(query: string): Promise<void> {
|
||||
const text = query.trim()
|
||||
const text = query.trim().slice(0, QUERY_MAX_LENGTH)
|
||||
if (!text) return
|
||||
cancelActive(true)
|
||||
|
||||
|
|
@ -171,30 +192,40 @@ export function useChatStream(createPlaylist: PlaylistCreator) {
|
|||
turns.value = [...turns.value, userTurn, assistantTurn]
|
||||
|
||||
const controller = new AbortController()
|
||||
activeController = controller
|
||||
activeTurnId = assistantTurn.id
|
||||
const requestState: ActiveRequest = {
|
||||
controller,
|
||||
turnId: assistantTurn.id,
|
||||
terminalReceived: false,
|
||||
}
|
||||
activeRequest = requestState
|
||||
|
||||
try {
|
||||
await streamRecommendations(request, controller.signal, (event) => {
|
||||
if (activeRequest !== requestState || requestState.terminalReceived) return
|
||||
if (event.type === 'done' || event.type === 'error') {
|
||||
requestState.terminalReceived = true
|
||||
}
|
||||
updateAssistant(assistantTurn.id, (turn) => reduceEvent(turn, event))
|
||||
addLog(event.type, JSON.stringify(event))
|
||||
})
|
||||
} catch (error) {
|
||||
if (activeRequest !== requestState || requestState.terminalReceived) return
|
||||
if (isAbortError(error, controller.signal)) return
|
||||
const failure: TransportFailure =
|
||||
error instanceof StreamTransportError
|
||||
? { kind: error.kind, message: error.message }
|
||||
: { kind: 'network', message: 'The recommendation request failed unexpectedly.' }
|
||||
let failure: TransportFailure
|
||||
if (error instanceof StreamTransportError) {
|
||||
failure = { kind: error.kind, message: error.message }
|
||||
} else {
|
||||
failure = { kind: 'network', message: messages.useChatStreamUnexpectedFailure }
|
||||
}
|
||||
updateAssistant(assistantTurn.id, (turn) => ({
|
||||
...turn,
|
||||
status: 'error',
|
||||
transportFailure: failure,
|
||||
}))
|
||||
addLog('transport', `${failure.kind}: ${failure.message}`)
|
||||
addLog(messages.useChatStreamTransportEvent, `${failure.kind}: ${failure.message}`)
|
||||
} finally {
|
||||
if (activeController === controller) {
|
||||
activeController = null
|
||||
activeTurnId = null
|
||||
if (activeRequest === requestState) {
|
||||
activeRequest = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -237,7 +268,7 @@ export function useChatStream(createPlaylist: PlaylistCreator) {
|
|||
status: 'error',
|
||||
name,
|
||||
url: null,
|
||||
message: 'Spotify could not create the playlist. Nothing was retried.',
|
||||
message: messages.useChatStreamPlaylistFailure,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
|
@ -256,7 +287,6 @@ export function useChatStream(createPlaylist: PlaylistCreator) {
|
|||
eventLog,
|
||||
isStreaming,
|
||||
turnCount,
|
||||
isEmpty,
|
||||
latestAssistant,
|
||||
send,
|
||||
savePlaylist,
|
||||
|
|
|
|||
46
frontend/src/lib/constants.ts
Normal file
46
frontend/src/lib/constants.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/** Shared frontend limits and timing values. */
|
||||
|
||||
// Mirrors the backend RecommendationRequest query max_length bound.
|
||||
export const QUERY_MAX_LENGTH = 1000
|
||||
|
||||
// Mirrors the backend RecommendationRequest history max_length bound.
|
||||
export const HISTORY_MAX_TURNS = 12
|
||||
|
||||
// Mirrors the backend RecommendationRequest prior_recommendations max_length bound.
|
||||
export const PRIOR_RECOMMENDATIONS_MAX_TRACKS = 50
|
||||
|
||||
// Mirrors the backend HistoryTurn content max_length bound.
|
||||
export const HISTORY_CONTENT_MAX_LENGTH = 2000
|
||||
|
||||
// Mirrors the backend PlaylistCreateRequest name max_length bound.
|
||||
export const PLAYLIST_NAME_MAX_LENGTH = 100
|
||||
|
||||
// Keeps the developer event log useful without allowing unbounded growth.
|
||||
export const EVENT_LOG_MAX_ENTRIES = 100
|
||||
|
||||
// Treats the message list as pinned when it is within this distance of the bottom.
|
||||
export const MESSAGE_LIST_PIN_THRESHOLD_PX = 48
|
||||
|
||||
// Matches the composer placeholder switch to the mobile layout breakpoint.
|
||||
export const NARROW_COMPOSER_MAX_WIDTH_PX = 559
|
||||
|
||||
// Keeps routine hover and focus transitions responsive.
|
||||
export const FAST_TRANSITION_DURATION_MS = 160
|
||||
|
||||
// Gives track cards enough time to settle without slowing streamed results.
|
||||
export const TRACK_CARD_ANIMATION_DURATION_MS = 340
|
||||
|
||||
// Makes new conversation turns noticeable without delaying interaction.
|
||||
export const MESSAGE_ENTRANCE_ANIMATION_DURATION_MS = 300
|
||||
|
||||
// Gives playlist confirmation a brief visual acknowledgement.
|
||||
export const PLAYLIST_SAVED_ANIMATION_DURATION_MS = 260
|
||||
|
||||
// Keeps the thinking pulse calm while work is in progress.
|
||||
export const THINKING_PULSE_ANIMATION_DURATION_MS = 1100
|
||||
|
||||
// Separates thinking dots enough to make their sequence legible.
|
||||
export const THINKING_PULSE_STAGGER_MS = 180
|
||||
|
||||
// Retains an effectively instant duration when reduced motion is requested.
|
||||
export const REDUCED_MOTION_DURATION_MS = 1
|
||||
149
frontend/src/lib/messages.ts
Normal file
149
frontend/src/lib/messages.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/** Central English copy for future localization. */
|
||||
|
||||
export const messages = {
|
||||
appHeaderBrandPrefix: 'discovery',
|
||||
appHeaderBrandAccent: '-by-',
|
||||
appHeaderBrandSuffix: 'llm',
|
||||
appHeaderKicker: 'proof of concept',
|
||||
appHeaderDemoMode: 'demo mode',
|
||||
appHeaderLiveMode: 'live mode',
|
||||
appHeaderModeUnavailable: 'mode unavailable',
|
||||
appHeaderCheckingMode: 'checking mode',
|
||||
appHeaderDevPanel: 'Dev panel',
|
||||
appHeaderDevPanelShort: 'Dev',
|
||||
appHeaderDevPanelShortcut: 'Ctrl+D',
|
||||
|
||||
assistantMessageReadingRequest: 'Reading the request',
|
||||
assistantMessageCheckingCandidates: 'Checking candidates',
|
||||
assistantMessageCheckingCandidateCount: 'Checking {count} candidates',
|
||||
assistantMessageStreamingTracks: 'Streaming verified tracks',
|
||||
assistantMessageAvatar: 'd',
|
||||
|
||||
authStatusCheckingConnection: 'Checking Spotify connection',
|
||||
authStatusConnectSpotify: 'Connect Spotify',
|
||||
authStatusConnect: 'Connect',
|
||||
authStatusTryAgain: 'Try Spotify again',
|
||||
authStatusRetry: 'Retry',
|
||||
authStatusConnectedAs: 'Connected as ',
|
||||
authStatusLoggingOut: 'Logging out...',
|
||||
authStatusLogOut: 'Log out',
|
||||
|
||||
chatViewNoTurns: 'no turns yet',
|
||||
chatViewOneTurn: '{count} turn in this session',
|
||||
chatViewManyTurns: '{count} turns in this session',
|
||||
|
||||
devPanelTitle: 'Dev panel',
|
||||
devPanelCloseLabel: 'Close dev panel',
|
||||
devPanelCloseSymbol: 'X',
|
||||
devPanelLatestRequest: 'Latest request',
|
||||
devPanelContractCaption: 'Only counters supplied by the recommendation contract are shown.',
|
||||
devPanelRequestId: 'request id',
|
||||
devPanelCandidateCount: 'candidate count',
|
||||
devPanelTrackCount: 'track count',
|
||||
devPanelTotalMilliseconds: 'total ms',
|
||||
devPanelWaiting: 'waiting',
|
||||
devPanelConversation: 'Conversation',
|
||||
devPanelClearConversation: 'Clear conversation',
|
||||
devPanelEventStream: 'Event stream',
|
||||
devPanelIdle: 'idle',
|
||||
devPanelWaitingForRequest: 'waiting for a request',
|
||||
|
||||
emptyResultsTitle: 'No verified tracks matched this request.',
|
||||
emptyResultsGuidance: 'Try broadening the moment or removing one constraint.',
|
||||
|
||||
emptyStateHeading: 'What should be playing',
|
||||
emptyStateHeadingAccent: ' right now?',
|
||||
emptyStateDescription:
|
||||
'Describe the moment, not the genre. The assistant reads the request, proposes candidates, verifies each one on Spotify and explains why it picked them.',
|
||||
emptyStateSuggestionLabel: 'Try one of these',
|
||||
|
||||
messageInputAriaLabel: 'Describe what you want to listen to',
|
||||
messageInputPlaceholder:
|
||||
'Describe the moment: "something calm while I am programming, but not boring"',
|
||||
messageInputNarrowPlaceholder: 'Describe the moment...',
|
||||
messageInputWorking: 'Working...',
|
||||
messageInputSend: 'Send',
|
||||
messageInputHint:
|
||||
'Enter to send / refine with follow-ups like "more electronic and drop number 3"',
|
||||
|
||||
messageListConversationLabel: 'Conversation',
|
||||
messageListRequestFailed: 'The recommendation request failed.',
|
||||
messageListTracksReady: '{count} tracks ready.',
|
||||
messageListWarning: 'Warning {count}: {message}',
|
||||
messageListReadingRequest: 'Reading the request.',
|
||||
messageListCheckingCandidates: 'Checking recommendation candidates.',
|
||||
messageListVerifiedTracksReceived: '{count} verified tracks received.',
|
||||
|
||||
modeBannerDemo: 'Demo mode: replaying recorded recommendation sessions',
|
||||
|
||||
playlistSavedConfirmationPrefix: 'Saved ',
|
||||
playlistSavedMetadata: '{count} tracks / private / created just now',
|
||||
playlistSavedOpenSpotify: 'Open in Spotify',
|
||||
|
||||
requestMetadataCandidates: '{count} candidates considered',
|
||||
requestMetadataVerified: '{count} verified',
|
||||
requestMetadataDuration: '{milliseconds} ms',
|
||||
|
||||
resultActionsSaving: 'Saving...',
|
||||
resultActionsTryAgain: 'Try saving again',
|
||||
resultActionsSavePlaylist: 'Save as playlist',
|
||||
resultActionsConnectSpotify: 'Connect Spotify to save',
|
||||
resultActionsSummary: '{count} verified tracks ready for a private playlist',
|
||||
|
||||
resultSetRecommendedTracksLabel: 'Recommended tracks',
|
||||
|
||||
streamErrorRetry: 'Edit and retry',
|
||||
|
||||
trackCardArtworkAlt: '{album} album artwork',
|
||||
trackCardOpenSpotify: 'Open in Spotify',
|
||||
|
||||
useApiInvalidCurrentUser: 'Invalid current user response.',
|
||||
useApiInvalidPlaylist: 'Invalid playlist response.',
|
||||
useApiLoginFailed: 'Spotify login did not complete. Please try again.',
|
||||
useApiAuthenticationCheckFailed: 'Authentication check failed.',
|
||||
useApiConnectionUnavailable: 'Spotify connection status is unavailable.',
|
||||
useApiHealthCheckFailed: 'Health check failed.',
|
||||
useApiInvalidHealth: 'Invalid health response.',
|
||||
useApiModeUnavailable: 'Service mode is unavailable.',
|
||||
useApiLogoutFailed: 'Logout failed.',
|
||||
useApiSpotifyLogoutFailed: 'Spotify logout failed. Refresh before trying again.',
|
||||
useApiPlaylistCreationFailed: 'Playlist creation returned {status}.',
|
||||
|
||||
useChatStreamTrackCountMismatch: 'The final track count did not match the streamed results.',
|
||||
useChatStreamPlaylistName: '[discovery-by-llm] {query}',
|
||||
useChatStreamRequestReplaced: 'This request was replaced by a newer request.',
|
||||
useChatStreamUnexpectedFailure: 'The recommendation request failed unexpectedly.',
|
||||
useChatStreamTransportEvent: 'transport',
|
||||
useChatStreamPlaylistFailure: 'Spotify could not create the playlist. Nothing was retried.',
|
||||
|
||||
recommendationStreamInvalidJson: 'The response contained invalid JSON.',
|
||||
recommendationStreamMissingMetadata: 'The stream did not begin with metadata.',
|
||||
recommendationStreamDuplicateMetadata: 'The stream contained duplicate metadata.',
|
||||
recommendationStreamAfterFinalEvent: 'The stream continued after its final event.',
|
||||
recommendationStreamUnavailable: 'The recommendation service could not be reached.',
|
||||
recommendationStreamHttpFailure: 'The recommendation service returned {status}.',
|
||||
recommendationStreamInvalidContentType: 'The response was not an NDJSON stream.',
|
||||
recommendationStreamEmpty: 'The response stream was empty.',
|
||||
recommendationStreamUnexpectedEnd:
|
||||
'The recommendation stream ended before a final event arrived.',
|
||||
recommendationStreamInterrupted: 'The recommendation stream was interrupted.',
|
||||
|
||||
streamParserInvalidField: 'Invalid {field} field.',
|
||||
streamParserInvalidTrack: 'Invalid track field.',
|
||||
streamParserMissingType: 'Stream record has no valid type.',
|
||||
streamParserUnknownEvent: 'Unknown stream event type: {type}.',
|
||||
} as const satisfies Readonly<Record<string, string>>
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
|
||||
/** Fill named placeholders in one extracted message. */
|
||||
export function formatMessage(
|
||||
key: MessageKey,
|
||||
values: Readonly<Record<string, string | number>>,
|
||||
): string {
|
||||
let message: string = messages[key]
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
message = message.replaceAll(`{${name}}`, String(value))
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { CurrentUser, DoneEvent, ErrorEvent, TrackEvent, WarningEvent } from './types'
|
||||
|
||||
export type AssistantStatus = 'streaming' | 'done' | 'error'
|
||||
type AssistantStatus = 'streaming' | 'done' | 'error'
|
||||
export type AppMode = 'demo' | 'live'
|
||||
export type TransportFailureKind =
|
||||
'cancelled' | 'http' | 'network' | 'parse' | 'protocol' | 'unexpected_eof'
|
||||
|
|
@ -17,7 +17,7 @@ export interface PlaylistState {
|
|||
message: string | null
|
||||
}
|
||||
|
||||
export interface UserTurn {
|
||||
interface UserTurn {
|
||||
id: string
|
||||
role: 'user'
|
||||
text: string
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { formatMessage, messages } from './messages'
|
||||
import type { RecommendationRequest, StreamEvent } from './types'
|
||||
import type { TransportFailureKind } from './models'
|
||||
import { parseStreamEvent, StreamParseError } from './streamParser'
|
||||
|
||||
type StreamPhase = 'metadata' | 'events' | 'terminal'
|
||||
|
||||
export class StreamTransportError extends Error {
|
||||
readonly kind: TransportFailureKind
|
||||
|
||||
|
|
@ -17,7 +20,7 @@ function parseLine(line: string): StreamEvent {
|
|||
try {
|
||||
value = JSON.parse(line)
|
||||
} catch {
|
||||
throw new StreamTransportError('parse', 'The response contained invalid JSON.')
|
||||
throw new StreamTransportError('parse', messages.recommendationStreamInvalidJson)
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -30,6 +33,43 @@ function parseLine(line: string): StreamEvent {
|
|||
}
|
||||
}
|
||||
|
||||
function advancePhase(phase: StreamPhase, event: StreamEvent): StreamPhase {
|
||||
if (phase === 'metadata') {
|
||||
if (event.type === 'metadata') return 'events'
|
||||
throw new StreamTransportError('protocol', messages.recommendationStreamMissingMetadata)
|
||||
}
|
||||
if (phase === 'events') {
|
||||
if (event.type === 'track' || event.type === 'warning') return 'events'
|
||||
if (event.type === 'done' || event.type === 'error') return 'terminal'
|
||||
throw new StreamTransportError('protocol', messages.recommendationStreamDuplicateMetadata)
|
||||
}
|
||||
throw new StreamTransportError('protocol', messages.recommendationStreamAfterFinalEvent)
|
||||
}
|
||||
|
||||
async function cancelBody(body: ReadableStream<Uint8Array> | null): Promise<void> {
|
||||
try {
|
||||
await body?.cancel()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelReader(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<void> {
|
||||
try {
|
||||
await reader.cancel()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function releaseReader(reader: ReadableStreamDefaultReader<Uint8Array>): void {
|
||||
try {
|
||||
reader.releaseLock()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** Post a recommendation request and consume its NDJSON event stream. */
|
||||
export async function streamRecommendations(
|
||||
request: RecommendationRequest,
|
||||
|
|
@ -47,26 +87,29 @@ export async function streamRecommendations(
|
|||
})
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw error
|
||||
throw new StreamTransportError('network', 'The recommendation service could not be reached.')
|
||||
throw new StreamTransportError('network', messages.recommendationStreamUnavailable)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
await cancelBody(response.body)
|
||||
throw new StreamTransportError(
|
||||
'http',
|
||||
`The recommendation service returned ${response.status}.`,
|
||||
formatMessage('recommendationStreamHttpFailure', { status: response.status }),
|
||||
)
|
||||
}
|
||||
const contentType = response.headers.get('content-type')?.split(';')[0].trim()
|
||||
if (contentType !== 'application/x-ndjson') {
|
||||
throw new StreamTransportError('protocol', 'The response was not an NDJSON stream.')
|
||||
await cancelBody(response.body)
|
||||
throw new StreamTransportError('protocol', messages.recommendationStreamInvalidContentType)
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new StreamTransportError('protocol', 'The response stream was empty.')
|
||||
throw new StreamTransportError('protocol', messages.recommendationStreamEmpty)
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let phase: StreamPhase = 'metadata'
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
|
|
@ -79,9 +122,10 @@ export async function streamRecommendations(
|
|||
const line = rawLine.trim()
|
||||
if (!line) continue
|
||||
const event = parseLine(line)
|
||||
phase = advancePhase(phase, event)
|
||||
onEvent(event)
|
||||
if (event.type === 'done' || event.type === 'error') {
|
||||
await reader.cancel()
|
||||
if (phase === 'terminal') {
|
||||
await cancelReader(reader)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -92,20 +136,19 @@ export async function streamRecommendations(
|
|||
const finalLine = buffer.trim()
|
||||
if (finalLine) {
|
||||
const event = parseLine(finalLine)
|
||||
phase = advancePhase(phase, event)
|
||||
onEvent(event)
|
||||
if (event.type === 'done' || event.type === 'error') return
|
||||
if (phase === 'terminal') return
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal.aborted || error instanceof StreamTransportError) throw error
|
||||
throw new StreamTransportError('network', 'The recommendation stream was interrupted.')
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
|
||||
throw new StreamTransportError(
|
||||
'unexpected_eof',
|
||||
'The recommendation stream ended before a final event arrived.',
|
||||
)
|
||||
throw new StreamTransportError('unexpected_eof', messages.recommendationStreamUnexpectedEnd)
|
||||
} catch (error) {
|
||||
await cancelReader(reader)
|
||||
if (signal.aborted || error instanceof StreamTransportError) throw error
|
||||
throw new StreamTransportError('network', messages.recommendationStreamInterrupted)
|
||||
} finally {
|
||||
releaseReader(reader)
|
||||
}
|
||||
}
|
||||
|
||||
/** Return whether a rejected stream operation was intentionally aborted. */
|
||||
|
|
|
|||
13
frontend/src/lib/spotifyUrl.ts
Normal file
13
frontend/src/lib/spotifyUrl.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/** Return a normalized Spotify web URL when the untrusted value is safe to link. */
|
||||
export function parseSpotifyUrl(value: string): string | null {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
const usesHttps = url.protocol === 'https:'
|
||||
const usesSpotifyHost = url.host === 'open.spotify.com'
|
||||
const hasCredentials = Boolean(url.username || url.password)
|
||||
if (!usesHttps || !usesSpotifyHost || hasCredentials) return null
|
||||
return url.href
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import { formatMessage, messages } from './messages'
|
||||
import type { StreamEvent, TrackCard } from './types'
|
||||
import { parseSpotifyUrl } from './spotifyUrl'
|
||||
|
||||
export class StreamParseError extends Error {
|
||||
constructor(message: string) {
|
||||
|
|
@ -13,14 +15,16 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||
|
||||
function stringField(value: Record<string, unknown>, key: string): string {
|
||||
const field = value[key]
|
||||
if (typeof field !== 'string') throw new StreamParseError(`Invalid ${key} field.`)
|
||||
if (typeof field !== 'string') {
|
||||
throw new StreamParseError(formatMessage('streamParserInvalidField', { field: key }))
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
function numberField(value: Record<string, unknown>, key: string): number {
|
||||
const field = value[key]
|
||||
if (typeof field !== 'number' || !Number.isFinite(field)) {
|
||||
throw new StreamParseError(`Invalid ${key} field.`)
|
||||
throw new StreamParseError(formatMessage('streamParserInvalidField', { field: key }))
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
|
@ -28,7 +32,7 @@ function numberField(value: Record<string, unknown>, key: string): number {
|
|||
function stringArrayField(value: Record<string, unknown>, key: string): string[] {
|
||||
const field = value[key]
|
||||
if (!Array.isArray(field) || !field.every((entry) => typeof entry === 'string')) {
|
||||
throw new StreamParseError(`Invalid ${key} field.`)
|
||||
throw new StreamParseError(formatMessage('streamParserInvalidField', { field: key }))
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
|
@ -36,13 +40,19 @@ function stringArrayField(value: Record<string, unknown>, key: string): string[]
|
|||
function nullableStringField(value: Record<string, unknown>, key: string): string | null {
|
||||
const field = value[key]
|
||||
if (field !== null && typeof field !== 'string') {
|
||||
throw new StreamParseError(`Invalid ${key} field.`)
|
||||
throw new StreamParseError(formatMessage('streamParserInvalidField', { field: key }))
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
function spotifyUrlField(value: Record<string, unknown>, key: string): string | null {
|
||||
const field = nullableStringField(value, key)
|
||||
if (field === null) return null
|
||||
return parseSpotifyUrl(field)
|
||||
}
|
||||
|
||||
function parseTrackCard(value: unknown): TrackCard {
|
||||
if (!isRecord(value)) throw new StreamParseError('Invalid track field.')
|
||||
if (!isRecord(value)) throw new StreamParseError(messages.streamParserInvalidTrack)
|
||||
return {
|
||||
id: stringField(value, 'id'),
|
||||
uri: stringField(value, 'uri'),
|
||||
|
|
@ -50,14 +60,14 @@ function parseTrackCard(value: unknown): TrackCard {
|
|||
artists: stringArrayField(value, 'artists'),
|
||||
album_name: stringField(value, 'album_name'),
|
||||
album_art_url: nullableStringField(value, 'album_art_url'),
|
||||
external_url: nullableStringField(value, 'external_url'),
|
||||
external_url: spotifyUrlField(value, 'external_url'),
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse one untrusted NDJSON value into the frozen event union. */
|
||||
export function parseStreamEvent(value: unknown): StreamEvent {
|
||||
if (!isRecord(value) || typeof value.type !== 'string') {
|
||||
throw new StreamParseError('Stream record has no valid type.')
|
||||
throw new StreamParseError(messages.streamParserMissingType)
|
||||
}
|
||||
|
||||
switch (value.type) {
|
||||
|
|
@ -94,6 +104,6 @@ export function parseStreamEvent(value: unknown): StreamEvent {
|
|||
total_ms: numberField(value, 'total_ms'),
|
||||
}
|
||||
default:
|
||||
throw new StreamParseError(`Unknown stream event type: ${value.type}.`)
|
||||
throw new StreamParseError(formatMessage('streamParserUnknownEvent', { type: value.type }))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@
|
|||
|
||||
--c-text: #e8ede8;
|
||||
--c-text-muted: #8b948c;
|
||||
--c-text-dim: #7d857e;
|
||||
--c-text-faint: #666e68;
|
||||
--c-text-ghost: #4e544f;
|
||||
--c-text-dim: #858e86;
|
||||
--c-text-faint: #808981;
|
||||
--c-text-ghost: #7d857e;
|
||||
--c-text-chip: #bec5bf;
|
||||
|
||||
--c-accent: #7fc96b;
|
||||
|
|
@ -74,9 +74,6 @@
|
|||
|
||||
--shadow-drawer: -24px 0 60px rgba(0, 0, 0, 0.45);
|
||||
--ease: cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||
--dur-fast: 0.16s;
|
||||
--dur-card: 0.34s;
|
||||
|
||||
color-scheme: dark;
|
||||
color: var(--c-text);
|
||||
background: var(--c-bg);
|
||||
|
|
@ -154,7 +151,7 @@ a:hover {
|
|||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-duration: 0.001s !important;
|
||||
transition-duration: 0.001s !important;
|
||||
animation-duration: var(--dur-reduced-motion) !important;
|
||||
transition-duration: var(--dur-reduced-motion) !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
202
frontend/tests/recommendationStream.test.ts
Normal file
202
frontend/tests/recommendationStream.test.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { streamRecommendations, StreamTransportError } from '../src/lib/recommendationStream'
|
||||
import type { StreamEvent } from '../src/lib/types'
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
function streamResponse(
|
||||
chunks: Uint8Array[],
|
||||
options: { close?: boolean; cancel?: () => void } = {},
|
||||
): Response {
|
||||
let index = 0
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
const chunk = chunks[index]
|
||||
if (chunk) {
|
||||
controller.enqueue(chunk)
|
||||
index += 1
|
||||
}
|
||||
if (options.close !== false && index === chunks.length) controller.close()
|
||||
},
|
||||
cancel() {
|
||||
options.cancel?.()
|
||||
},
|
||||
})
|
||||
return new Response(body, {
|
||||
headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8' },
|
||||
})
|
||||
}
|
||||
|
||||
function stubResponse(response: Response): void {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => response),
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('streamRecommendations', () => {
|
||||
it('decodes fragmented NDJSON across JSON and multi-byte boundaries', async () => {
|
||||
const symbol = String.fromCodePoint(0x1f3b5)
|
||||
const records = [
|
||||
{
|
||||
type: 'metadata',
|
||||
request_id: 'request-1',
|
||||
intent_summary: `Focused ${symbol} listening`,
|
||||
candidate_count: 4,
|
||||
},
|
||||
{
|
||||
type: 'track',
|
||||
rank: 1,
|
||||
track: {
|
||||
id: 'track-1',
|
||||
uri: 'spotify:track:1',
|
||||
title: `Signal ${symbol}`,
|
||||
artists: ['Artist'],
|
||||
album_name: 'Album',
|
||||
album_art_url: null,
|
||||
external_url: 'https://open.spotify.com/track/1',
|
||||
},
|
||||
justification: 'It fits.',
|
||||
},
|
||||
{ type: 'warning', code: 'limited_pool', message: 'The pool was limited.' },
|
||||
{ type: 'warning', code: 'limited_pool', message: 'The pool stayed limited.' },
|
||||
{ type: 'done', track_count: 1, total_ms: 12 },
|
||||
]
|
||||
const payload = `${records.map((record) => JSON.stringify(record)).join('\n')}\n`
|
||||
const bytes = encoder.encode(payload)
|
||||
const jsonCut = encoder.encode(payload.slice(0, payload.indexOf('candidate_count') + 5)).length
|
||||
const symbolIndex = payload.indexOf(symbol)
|
||||
const symbolCut = encoder.encode(payload.slice(0, symbolIndex)).length + 2
|
||||
const cuts = [jsonCut, symbolCut, bytes.length - 8].sort((left, right) => left - right)
|
||||
const chunks = [
|
||||
bytes.slice(0, cuts[0]),
|
||||
bytes.slice(cuts[0], cuts[1]),
|
||||
bytes.slice(cuts[1], cuts[2]),
|
||||
bytes.slice(cuts[2]),
|
||||
]
|
||||
stubResponse(streamResponse(chunks))
|
||||
const events: StreamEvent[] = []
|
||||
|
||||
await streamRecommendations(
|
||||
{ schema_version: 1, query: 'focus', history: [], prior_recommendations: [] },
|
||||
new AbortController().signal,
|
||||
(event) => events.push(event),
|
||||
)
|
||||
|
||||
expect(events).toHaveLength(5)
|
||||
expect(events[0]).toMatchObject({
|
||||
type: 'metadata',
|
||||
intent_summary: `Focused ${symbol} listening`,
|
||||
})
|
||||
expect(events[1]).toMatchObject({ type: 'track', track: { title: `Signal ${symbol}` } })
|
||||
expect(events.filter((event) => event.type === 'warning')).toHaveLength(2)
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done', track_count: 1 })
|
||||
})
|
||||
|
||||
it('treats an error as terminal without waiting for done', async () => {
|
||||
let wasCancelled = false
|
||||
const payload = [
|
||||
JSON.stringify({
|
||||
type: 'metadata',
|
||||
request_id: 'request-1',
|
||||
intent_summary: 'Intent',
|
||||
candidate_count: 0,
|
||||
}),
|
||||
JSON.stringify({ type: 'error', code: 'upstream', message: 'Upstream failed.' }),
|
||||
JSON.stringify({ type: 'done', track_count: 0, total_ms: 4 }),
|
||||
].join('\n')
|
||||
stubResponse(
|
||||
streamResponse([encoder.encode(payload)], {
|
||||
close: false,
|
||||
cancel: () => {
|
||||
wasCancelled = true
|
||||
throw new Error('Cleanup failed.')
|
||||
},
|
||||
}),
|
||||
)
|
||||
const events: StreamEvent[] = []
|
||||
|
||||
await streamRecommendations(
|
||||
{ schema_version: 1, query: 'focus', history: [], prior_recommendations: [] },
|
||||
new AbortController().signal,
|
||||
(event) => events.push(event),
|
||||
)
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual(['metadata', 'error'])
|
||||
expect(wasCancelled).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an event before metadata', [{ type: 'warning', code: 'early', message: 'No metadata.' }]],
|
||||
[
|
||||
'duplicate metadata',
|
||||
[
|
||||
{ type: 'metadata', request_id: 'one', intent_summary: 'First', candidate_count: 1 },
|
||||
{ type: 'metadata', request_id: 'two', intent_summary: 'Second', candidate_count: 1 },
|
||||
],
|
||||
],
|
||||
])('rejects %s and cancels the reader', async (_label, records) => {
|
||||
let wasCancelled = false
|
||||
const payload = `${records.map((record) => JSON.stringify(record)).join('\n')}\n`
|
||||
stubResponse(
|
||||
streamResponse([encoder.encode(payload)], {
|
||||
close: false,
|
||||
cancel: () => {
|
||||
wasCancelled = true
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const result = streamRecommendations(
|
||||
{ schema_version: 1, query: 'focus', history: [], prior_recommendations: [] },
|
||||
new AbortController().signal,
|
||||
() => undefined,
|
||||
)
|
||||
|
||||
await expect(result).rejects.toMatchObject<Partial<StreamTransportError>>({ kind: 'protocol' })
|
||||
expect(wasCancelled).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels the reader after a parse failure', async () => {
|
||||
let wasCancelled = false
|
||||
stubResponse(
|
||||
streamResponse([encoder.encode('{not-json}\n')], {
|
||||
close: false,
|
||||
cancel: () => {
|
||||
wasCancelled = true
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const result = streamRecommendations(
|
||||
{ schema_version: 1, query: 'focus', history: [], prior_recommendations: [] },
|
||||
new AbortController().signal,
|
||||
() => undefined,
|
||||
)
|
||||
|
||||
await expect(result).rejects.toMatchObject<Partial<StreamTransportError>>({ kind: 'parse' })
|
||||
expect(wasCancelled).toBe(true)
|
||||
})
|
||||
|
||||
it('reports a transport failure when the body ends before a terminal event', async () => {
|
||||
const payload = `${JSON.stringify({
|
||||
type: 'metadata',
|
||||
request_id: 'request-1',
|
||||
intent_summary: 'Intent',
|
||||
candidate_count: 0,
|
||||
})}\n`
|
||||
stubResponse(streamResponse([encoder.encode(payload)]))
|
||||
|
||||
const result = streamRecommendations(
|
||||
{ schema_version: 1, query: 'focus', history: [], prior_recommendations: [] },
|
||||
new AbortController().signal,
|
||||
() => undefined,
|
||||
)
|
||||
|
||||
await expect(result).rejects.toMatchObject<Partial<StreamTransportError>>({
|
||||
kind: 'unexpected_eof',
|
||||
})
|
||||
})
|
||||
})
|
||||
79
frontend/tests/spotifyUrl.test.ts
Normal file
79
frontend/tests/spotifyUrl.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { createApp, h } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import PlaylistSaved from '../src/components/PlaylistSaved.vue'
|
||||
import { useApi } from '../src/composables/useApi'
|
||||
import { parseSpotifyUrl } from '../src/lib/spotifyUrl'
|
||||
import { parseStreamEvent } from '../src/lib/streamParser'
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('parseSpotifyUrl', () => {
|
||||
it.each([
|
||||
['https://open.spotify.com/track/123', 'https://open.spotify.com/track/123'],
|
||||
[
|
||||
'https://open.spotify.com/playlist/123?si=abc',
|
||||
'https://open.spotify.com/playlist/123?si=abc',
|
||||
],
|
||||
])('accepts %s', (value, expected) => {
|
||||
expect(parseSpotifyUrl(value)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'http://open.spotify.com/track/123',
|
||||
'https://embed.spotify.com/track/123',
|
||||
'https://open.spotify.com:8443/track/123',
|
||||
'https://open.spotify.com.evil.example/track/123',
|
||||
'https://user@open.spotify.com/track/123',
|
||||
'javascript:alert(1)',
|
||||
'/track/123',
|
||||
'not a url',
|
||||
])('rejects %s', (value) => {
|
||||
expect(parseSpotifyUrl(value)).toBeNull()
|
||||
})
|
||||
|
||||
it('removes an unsafe track link at the event parsing boundary', () => {
|
||||
const event = parseStreamEvent({
|
||||
type: 'track',
|
||||
rank: 1,
|
||||
track: {
|
||||
id: 'track-1',
|
||||
uri: 'spotify:track:1',
|
||||
title: 'Track',
|
||||
artists: ['Artist'],
|
||||
album_name: 'Album',
|
||||
album_art_url: null,
|
||||
external_url: 'https://evil.example/track/1',
|
||||
},
|
||||
justification: 'It fits.',
|
||||
})
|
||||
|
||||
expect(event).toMatchObject({ type: 'track', track: { external_url: null } })
|
||||
})
|
||||
|
||||
it('keeps playlist confirmation but removes an unsafe API link', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ url: 'https://evil.example/playlist/1' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
)
|
||||
const { createPlaylist } = useApi()
|
||||
const result = await createPlaylist({
|
||||
schema_version: 1,
|
||||
name: 'Playlist',
|
||||
track_uris: ['spotify:track:1'],
|
||||
})
|
||||
const root = document.createElement('div')
|
||||
const app = createApp({
|
||||
render: () => h(PlaylistSaved, { name: 'Playlist', url: result.url, trackCount: 1 }),
|
||||
})
|
||||
app.mount(root)
|
||||
|
||||
expect(root.textContent).toContain('Saved Playlist')
|
||||
expect(root.querySelector('a')).toBeNull()
|
||||
app.unmount()
|
||||
})
|
||||
})
|
||||
242
frontend/tests/useChatStream.test.ts
Normal file
242
frontend/tests/useChatStream.test.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import { createApp, defineComponent, h } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useChatStream } from '../src/composables/useChatStream'
|
||||
import { streamRecommendations, StreamTransportError } from '../src/lib/recommendationStream'
|
||||
import type { AssistantTurn } from '../src/lib/models'
|
||||
import type { RecommendationRequest, StreamEvent, TrackEvent } from '../src/lib/types'
|
||||
|
||||
vi.mock('../src/lib/recommendationStream', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../src/lib/recommendationStream')>()
|
||||
return { ...actual, streamRecommendations: vi.fn() }
|
||||
})
|
||||
|
||||
const streamMock = vi.mocked(streamRecommendations)
|
||||
const unmountCallbacks: Array<() => void> = []
|
||||
|
||||
function mountChat() {
|
||||
let chat: ReturnType<typeof useChatStream> | undefined
|
||||
const root = document.createElement('div')
|
||||
document.body.append(root)
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
chat = useChatStream(vi.fn(async () => ({ url: null })))
|
||||
return () => h('div')
|
||||
},
|
||||
}),
|
||||
)
|
||||
app.mount(root)
|
||||
unmountCallbacks.push(() => {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
})
|
||||
if (!chat) throw new Error('Chat composable did not mount.')
|
||||
return chat
|
||||
}
|
||||
|
||||
function metadata(requestId: string): StreamEvent {
|
||||
return {
|
||||
type: 'metadata',
|
||||
request_id: requestId,
|
||||
intent_summary: `Intent for ${requestId}`,
|
||||
candidate_count: 100,
|
||||
}
|
||||
}
|
||||
|
||||
function track(rank: number): TrackEvent {
|
||||
return {
|
||||
type: 'track',
|
||||
rank,
|
||||
track: {
|
||||
id: `track-${rank}`,
|
||||
uri: `spotify:track:${rank}`,
|
||||
title: `Track ${rank}`,
|
||||
artists: ['Artist'],
|
||||
album_name: 'Album',
|
||||
album_art_url: null,
|
||||
external_url: null,
|
||||
},
|
||||
justification: 'It fits.',
|
||||
}
|
||||
}
|
||||
|
||||
function assistantTurns(chat: ReturnType<typeof useChatStream>): AssistantTurn[] {
|
||||
return chat.turns.value.filter((turn): turn is AssistantTurn => turn.role === 'assistant')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
streamMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
while (unmountCallbacks.length) unmountCallbacks.pop()?.()
|
||||
})
|
||||
|
||||
describe('useChatStream', () => {
|
||||
it('retains repeated warnings that share a code', async () => {
|
||||
streamMock.mockImplementation(async (_request, _signal, onEvent) => {
|
||||
onEvent(metadata('warnings'))
|
||||
onEvent({ type: 'warning', code: 'limited_pool', message: 'First warning.' })
|
||||
onEvent({ type: 'warning', code: 'limited_pool', message: 'Second warning.' })
|
||||
onEvent({ type: 'done', track_count: 0, total_ms: 5 })
|
||||
})
|
||||
const chat = mountChat()
|
||||
|
||||
await chat.send('warnings')
|
||||
|
||||
expect(assistantTurns(chat)[0]?.warnings).toEqual([
|
||||
{ type: 'warning', code: 'limited_pool', message: 'First warning.' },
|
||||
{ type: 'warning', code: 'limited_pool', message: 'Second warning.' },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps error terminal when a late done callback arrives', async () => {
|
||||
streamMock.mockImplementation(async (_request, _signal, onEvent) => {
|
||||
onEvent(metadata('error'))
|
||||
onEvent({ type: 'error', code: 'upstream', message: 'Upstream failed.' })
|
||||
onEvent({ type: 'done', track_count: 0, total_ms: 5 })
|
||||
})
|
||||
const chat = mountChat()
|
||||
|
||||
await chat.send('error')
|
||||
|
||||
const turn = assistantTurns(chat)[0]
|
||||
expect(turn).toMatchObject({ status: 'error', error: { code: 'upstream' } })
|
||||
expect(turn?.completion).toBeNull()
|
||||
expect(turn?.transportFailure).toBeNull()
|
||||
})
|
||||
|
||||
it('stores an incomplete stream as a transport failure', async () => {
|
||||
streamMock.mockRejectedValue(
|
||||
new StreamTransportError('unexpected_eof', 'The stream ended early.'),
|
||||
)
|
||||
const chat = mountChat()
|
||||
|
||||
await chat.send('failure')
|
||||
|
||||
expect(assistantTurns(chat)[0]).toMatchObject({
|
||||
status: 'error',
|
||||
transportFailure: { kind: 'unexpected_eof' },
|
||||
})
|
||||
})
|
||||
|
||||
it('caps request history at 12 turns', async () => {
|
||||
const requests: RecommendationRequest[] = []
|
||||
streamMock.mockImplementation(async (request, _signal, onEvent) => {
|
||||
requests.push(request)
|
||||
onEvent(metadata(request.query))
|
||||
onEvent({ type: 'done', track_count: 0, total_ms: 5 })
|
||||
})
|
||||
const chat = mountChat()
|
||||
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
await chat.send(`query ${index}`)
|
||||
}
|
||||
|
||||
expect(requests.at(-1)?.history).toHaveLength(12)
|
||||
expect(requests.at(-1)?.history[0]?.role).toBe('user')
|
||||
})
|
||||
|
||||
it('caps prior recommendations at 50 tracks', async () => {
|
||||
const requests: RecommendationRequest[] = []
|
||||
streamMock.mockImplementation(async (request, _signal, onEvent) => {
|
||||
requests.push(request)
|
||||
onEvent(metadata(request.query))
|
||||
if (requests.length === 1) {
|
||||
for (let rank = 1; rank <= 60; rank += 1) onEvent(track(rank))
|
||||
onEvent({ type: 'done', track_count: 60, total_ms: 5 })
|
||||
} else {
|
||||
onEvent({ type: 'done', track_count: 0, total_ms: 5 })
|
||||
}
|
||||
})
|
||||
const chat = mountChat()
|
||||
|
||||
await chat.send('first')
|
||||
await chat.send('second')
|
||||
|
||||
expect(requests[1]?.prior_recommendations).toHaveLength(50)
|
||||
expect(requests[1]?.prior_recommendations.at(-1)?.rank).toBe(50)
|
||||
})
|
||||
|
||||
it('aborts a replaced stream and ignores its late callbacks and failure', async () => {
|
||||
interface PendingStream {
|
||||
signal: AbortSignal
|
||||
onEvent: (event: StreamEvent) => void
|
||||
resolve: () => void
|
||||
reject: (error: unknown) => void
|
||||
}
|
||||
const pending: PendingStream[] = []
|
||||
streamMock.mockImplementation(
|
||||
(_request, signal, onEvent) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
pending.push({ signal, onEvent, resolve, reject })
|
||||
}),
|
||||
)
|
||||
const chat = mountChat()
|
||||
|
||||
const firstSend = chat.send('first')
|
||||
const secondSend = chat.send('second')
|
||||
expect(pending[0]?.signal.aborted).toBe(true)
|
||||
|
||||
pending[0]?.onEvent(metadata('late-first'))
|
||||
pending[0]?.onEvent(track(1))
|
||||
pending[0]?.reject(new DOMException('Aborted', 'AbortError'))
|
||||
pending[1]?.onEvent(metadata('second'))
|
||||
pending[1]?.onEvent({ type: 'done', track_count: 0, total_ms: 5 })
|
||||
pending[1]?.resolve()
|
||||
await Promise.all([firstSend, secondSend])
|
||||
|
||||
const [firstTurn, secondTurn] = assistantTurns(chat)
|
||||
expect(firstTurn).toMatchObject({
|
||||
status: 'error',
|
||||
requestId: null,
|
||||
tracks: [],
|
||||
transportFailure: { kind: 'cancelled' },
|
||||
})
|
||||
expect(secondTurn).toMatchObject({ status: 'done', requestId: 'second' })
|
||||
})
|
||||
|
||||
it('does not relabel a terminal turn while its reader is cleaning up', async () => {
|
||||
interface PendingStream {
|
||||
signal: AbortSignal
|
||||
onEvent: (event: StreamEvent) => void
|
||||
resolve: () => void
|
||||
}
|
||||
const pending: PendingStream[] = []
|
||||
streamMock.mockImplementation(
|
||||
(_request, signal, onEvent) =>
|
||||
new Promise<void>((resolve) => {
|
||||
pending.push({ signal, onEvent, resolve })
|
||||
}),
|
||||
)
|
||||
const chat = mountChat()
|
||||
|
||||
const firstSend = chat.send('first')
|
||||
pending[0]?.onEvent(metadata('first'))
|
||||
pending[0]?.onEvent({ type: 'done', track_count: 0, total_ms: 5 })
|
||||
const secondSend = chat.send('second')
|
||||
expect(pending[0]?.signal.aborted).toBe(true)
|
||||
|
||||
pending[0]?.resolve()
|
||||
pending[1]?.onEvent(metadata('second'))
|
||||
pending[1]?.onEvent({ type: 'done', track_count: 0, total_ms: 5 })
|
||||
pending[1]?.resolve()
|
||||
await Promise.all([firstSend, secondSend])
|
||||
|
||||
expect(assistantTurns(chat).map((turn) => turn.status)).toEqual(['done', 'done'])
|
||||
})
|
||||
|
||||
it('caps a programmatic query at 1000 characters', async () => {
|
||||
let receivedQuery = ''
|
||||
streamMock.mockImplementation(async (request, _signal, onEvent) => {
|
||||
receivedQuery = request.query
|
||||
onEvent(metadata('query-limit'))
|
||||
onEvent({ type: 'done', track_count: 0, total_ms: 5 })
|
||||
})
|
||||
const chat = mountChat()
|
||||
|
||||
await chat.send('x'.repeat(1200))
|
||||
|
||||
expect(receivedQuery).toHaveLength(1000)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8888',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue