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/dist/wineater-chatbot.mjs +12 -11
- package/dist/wineater-chatbot.umd.js +12 -11
- package/package.json +1 -1
- package/src/App.svelte +59 -22
- package/src/services/api/api.ts +63 -0
package/package.json
CHANGED
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
|
package/src/services/api/api.ts
CHANGED
|
@@ -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 });
|