wineater-bot 4.21.0 → 4.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wineater-bot",
3
- "version": "4.21.0",
3
+ "version": "4.22.0",
4
4
  "description": "",
5
5
  "main": "dist/wineater-chatbot.mjs",
6
6
  "module": "src/index.js",
package/src/App.svelte CHANGED
@@ -171,6 +171,8 @@
171
171
  userActed = true;
172
172
 
173
173
  let query = '';
174
+ // id этого поиска: ответы и finally более старого поиска не трогают новый
175
+ let myQueryId: string | undefined;
174
176
  try {
175
177
  isLoading = true;
176
178
  clearError();
@@ -178,6 +180,7 @@
178
180
  query = searchQuery.trim() + ';';
179
181
  lastSearchedQuery = query;
180
182
  currentQueryId = newQueryId();
183
+ myQueryId = currentQueryId;
181
184
  try {
182
185
  trackWidgetEvent(WIDGET_EVENTS.SEARCH_PERFORMED, storeId, { query });
183
186
  } catch (error) {
@@ -185,9 +188,46 @@
185
188
  }
186
189
 
187
190
  // Запрос уходит сразу, карточки уезжают вверх, пока он в полёте.
191
+ // v2 идёт потоком: 4 вина приходят, как только их выбрал реранкер,
192
+ // причины («почему») — следом, в том же ответе. Они видны только в
193
+ // попапе, поэтому плитки показываем, не дожидаясь их.
194
+ let animationDone = false;
195
+ let shown = false;
196
+ let pendingEarly: RecommendedWine[] | null = null;
197
+
198
+ const showResults = (searchResults: RecommendedWine[]) => {
199
+ if (!searchResults || searchResults.length === 0) {
200
+ results = [];
201
+ translatedPopups = [];
202
+ errorMessage = getTranslation({ key: 'noResultsMessage', lang: appLanguage });
203
+ trackWidgetEvent(WIDGET_EVENTS.NO_RESULTS_FOUND, storeId, { query });
204
+ return;
205
+ }
206
+ results = searchResults;
207
+ isLoading = false;
208
+ if (shown) return;
209
+ shown = true;
210
+ // Пагинация доступна только для search/v2 — у instant/legacy-путей нет
211
+ // "more"-эндпоинта. Неполная выдача (< 4) означает, что кандидаты и так
212
+ // кончились: кнопка в этом случае только мигнула бы впустую.
213
+ hasMore = apiVersion === '2' && searchResults.length >= 4;
214
+ feedbackShown(searchResults, query);
215
+ trackWidgetEvent(WIDGET_EVENTS.SEARCH_RESULTS_DISPLAYED, storeId, {
216
+ results_count: searchResults.length,
217
+ results: searchResults,
218
+ query
219
+ });
220
+ };
221
+
222
+ const onWines = (early: RecommendedWine[]) => {
223
+ if (myQueryId !== currentQueryId) return; // устаревший поиск
224
+ if (!animationDone) pendingEarly = early;
225
+ else showResults(early);
226
+ };
227
+
228
+ const request = wineaterApi.searchStream(query, currentQueryId, onWines);
188
229
  // Пустой catch нужен, чтобы отказ запроса во время анимации не стал
189
230
  // unhandled rejection: ошибку обработает await ниже.
190
- const request = wineaterApi.getWineRecommendationsSearch(query, currentQueryId);
191
231
  request.catch(() => {});
192
232
 
193
233
  // шаг 1: плавно поднять карточки
@@ -198,29 +238,26 @@
198
238
  isAnimating = false;
199
239
  hasMore = false;
200
240
  seenIds = [];
201
-
202
- const searchResults = await request;
203
-
204
- if (!searchResults || searchResults.length === 0) {
205
- results = [];
206
- translatedPopups = [];
207
- errorMessage = getTranslation({ key: 'noResultsMessage', lang: appLanguage });
208
-
209
- trackWidgetEvent(WIDGET_EVENTS.NO_RESULTS_FOUND, storeId, { query });
241
+ animationDone = true;
242
+ if (pendingEarly) showResults(pendingEarly);
243
+
244
+ const finalResults = await request;
245
+ if (myQueryId !== currentQueryId) return;
246
+
247
+ // Тот же состав — дописываем причины в уже показанные плитки, не
248
+ // перерисовывая сетку под курсором; иначе (fallback, редкие расхождения) — заменяем.
249
+ const sameWines =
250
+ shown &&
251
+ finalResults.length === results.length &&
252
+ finalResults.every((w, i) => w.id === results[i]?.id);
253
+ if (sameWines) {
254
+ const byId = new Map(finalResults.map((w) => [w.id, w]));
255
+ results = results.map((w) => ({ ...w, reason: byId.get(w.id)?.reason ?? w.reason }));
210
256
  } else {
211
- results = searchResults;
212
- // Пагинация доступна только для search/v2 — у instant/legacy-путей нет
213
- // "more"-эндпоинта. Неполная выдача (< 4) означает, что кандидаты и так
214
- // кончились: кнопка в этом случае только мигнула бы впустую.
215
- hasMore = apiVersion === '2' && searchResults.length >= 4;
216
- feedbackShown(searchResults, query);
217
- trackWidgetEvent(WIDGET_EVENTS.SEARCH_RESULTS_DISPLAYED, storeId, {
218
- results_count: searchResults.length,
219
- results: searchResults,
220
- query
221
- });
257
+ showResults(finalResults);
222
258
  }
223
259
  } catch (error) {
260
+ if (myQueryId !== currentQueryId) return;
224
261
  results = [];
225
262
  console.error('[wineater] search error:', error);
226
263
  trackWidgetEvent(WIDGET_EVENTS.ERROR_OCCURRED, storeId, {
@@ -229,7 +266,7 @@
229
266
  });
230
267
  setSearchError();
231
268
  } finally {
232
- isLoading = false;
269
+ if (myQueryId === currentQueryId) isLoading = false;
233
270
  }
234
271
  }
235
272
 
@@ -120,6 +120,69 @@ class WineaterApi {
120
120
  }
121
121
  }
122
122
 
123
+ /**
124
+ * v2 search over NDJSON (`search/v2/stream`): `onWines` fires as soon as the
125
+ * reranker has picked the 4 wines (~1 s before the reasons are written), with
126
+ * `reason` empty; the promise resolves with the final wines including reasons.
127
+ * Any transport problem (no stream support, HTTP error, old backend without
128
+ * the endpoint) falls back to the plain JSON request — same result, later.
129
+ */
130
+ async searchStream(
131
+ text: string,
132
+ queryId: string | undefined,
133
+ onWines: (wines: RecommendedWine[]) => void,
134
+ ): Promise<RecommendedWine[]> {
135
+ const fallback = () => this.getWineRecommendationsSearch(text, queryId);
136
+ if (this.#apiVersion !== '2') return fallback();
137
+
138
+ let response: Response;
139
+ try {
140
+ response = await fetch(`${this.#baseUrl}dish/recommendations/search/v2/stream`, {
141
+ method: 'POST',
142
+ headers: this.#headers(),
143
+ body: JSON.stringify(queryId ? { text, query_id: queryId } : { text }),
144
+ });
145
+ } catch {
146
+ // network / CORS (incl. the uid header) — the JSON path knows how to recover
147
+ return fallback();
148
+ }
149
+ if (!response.ok || !response.body) return fallback();
150
+
151
+ const reader = response.body.getReader();
152
+ const decoder = new TextDecoder();
153
+ let buffer = '';
154
+ let early: RecommendedWine[] | null = null;
155
+ const toWines = (recs: any[]) => (recs ?? []).filter(Boolean).map(deserializeRecommendedWine);
156
+
157
+ try {
158
+ for (;;) {
159
+ const { done, value } = await reader.read();
160
+ if (value) buffer += decoder.decode(value, { stream: true });
161
+ const lines = buffer.split('\n');
162
+ buffer = done ? '' : lines.pop() ?? '';
163
+ for (const line of done ? lines.filter(Boolean) : lines) {
164
+ if (!line.trim()) continue;
165
+ const event = JSON.parse(line);
166
+ if (event.type === 'wines') {
167
+ // reasons are not written yet — show none rather than a placeholder
168
+ early = toWines(event.recommendations).map((w) => ({ ...w, reason: '' }));
169
+ onWines(early);
170
+ } else if (event.type === 'final') {
171
+ return toWines(event.recommendations);
172
+ } else if (event.type === 'error') {
173
+ if (early) return early;
174
+ return fallback();
175
+ }
176
+ }
177
+ if (done) break;
178
+ }
179
+ } catch (error) {
180
+ if (early) return early;
181
+ return fallback();
182
+ }
183
+ return early ?? fallback();
184
+ }
185
+
123
186
  async getMoreWineRecommendations(text: string, excludeIds: string[]): Promise<{ recommendations: RecommendedWine[]; hasMore: boolean }> {
124
187
  try {
125
188
  const data = await this.#request<any>('dish/recommendations/search/v2/more', { text, exclude_ids: excludeIds });