@ykurilov/agent-chat-videos 0.0.0-stage → 0.55.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/CHANGELOG.md +369 -0
- package/LICENSE +21 -0
- package/README.md +39 -2
- package/dist/chunk-UROHGMNR.js +139 -0
- package/dist/chunk-UROHGMNR.js.map +1 -0
- package/dist/index.js +203 -0
- package/dist/index.js.map +1 -0
- package/dist/react/index.js +43 -0
- package/dist/react/index.js.map +1 -0
- package/dist/types/create-video-library.d.ts +48 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/react/index.d.ts +2 -0
- package/dist/types/react/videos-client.d.ts +6 -0
- package/dist/types/time-label.d.ts +2 -0
- package/dist/types/video-address.d.ts +19 -0
- package/dist/types/video-catalog.d.ts +24 -0
- package/package.json +60 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
# @ykurilov/agent-chat-videos
|
|
2
|
+
|
|
3
|
+
## 0.55.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [cdc7dd7]
|
|
8
|
+
- @ykurilov/agent-chat-react@0.55.0
|
|
9
|
+
|
|
10
|
+
## 0.54.0
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- Updated dependencies [c8fb0dd]
|
|
15
|
+
- Updated dependencies [73d3aaa]
|
|
16
|
+
- @ykurilov/agent-chat-react@0.54.0
|
|
17
|
+
|
|
18
|
+
## 0.53.0
|
|
19
|
+
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- Updated dependencies [b6a7786]
|
|
23
|
+
- @ykurilov/agent-chat-react@0.53.0
|
|
24
|
+
|
|
25
|
+
## 0.52.0
|
|
26
|
+
|
|
27
|
+
### Patch Changes
|
|
28
|
+
|
|
29
|
+
- @ykurilov/agent-chat-react@0.52.0
|
|
30
|
+
|
|
31
|
+
## 0.51.0
|
|
32
|
+
|
|
33
|
+
### Patch Changes
|
|
34
|
+
|
|
35
|
+
- @ykurilov/agent-chat-react@0.51.0
|
|
36
|
+
|
|
37
|
+
## 0.50.0
|
|
38
|
+
|
|
39
|
+
### Patch Changes
|
|
40
|
+
|
|
41
|
+
- @ykurilov/agent-chat-react@0.50.0
|
|
42
|
+
|
|
43
|
+
## 0.49.0
|
|
44
|
+
|
|
45
|
+
### Patch Changes
|
|
46
|
+
|
|
47
|
+
- Updated dependencies [084cef7]
|
|
48
|
+
- @ykurilov/agent-chat-react@0.49.0
|
|
49
|
+
|
|
50
|
+
## 0.48.0
|
|
51
|
+
|
|
52
|
+
### Patch Changes
|
|
53
|
+
|
|
54
|
+
- @ykurilov/agent-chat-react@0.48.0
|
|
55
|
+
|
|
56
|
+
## 0.47.0
|
|
57
|
+
|
|
58
|
+
### Patch Changes
|
|
59
|
+
|
|
60
|
+
- @ykurilov/agent-chat-react@0.47.0
|
|
61
|
+
|
|
62
|
+
## 0.46.2
|
|
63
|
+
|
|
64
|
+
### Patch Changes
|
|
65
|
+
|
|
66
|
+
- @ykurilov/agent-chat-react@0.46.2
|
|
67
|
+
|
|
68
|
+
## 0.46.1
|
|
69
|
+
|
|
70
|
+
### Patch Changes
|
|
71
|
+
|
|
72
|
+
- @ykurilov/agent-chat-react@0.46.1
|
|
73
|
+
|
|
74
|
+
## 0.46.0
|
|
75
|
+
|
|
76
|
+
### Patch Changes
|
|
77
|
+
|
|
78
|
+
- Updated dependencies [56d7528]
|
|
79
|
+
- @ykurilov/agent-chat-react@0.46.0
|
|
80
|
+
|
|
81
|
+
## 0.45.0
|
|
82
|
+
|
|
83
|
+
### Patch Changes
|
|
84
|
+
|
|
85
|
+
- Updated dependencies [eb74c58]
|
|
86
|
+
- Updated dependencies [ac9aede]
|
|
87
|
+
- @ykurilov/agent-chat-react@0.45.0
|
|
88
|
+
|
|
89
|
+
## 0.44.0
|
|
90
|
+
|
|
91
|
+
### Patch Changes
|
|
92
|
+
|
|
93
|
+
- Updated dependencies [6a2b5d9]
|
|
94
|
+
- @ykurilov/agent-chat-react@0.44.0
|
|
95
|
+
|
|
96
|
+
## 0.43.0
|
|
97
|
+
|
|
98
|
+
### Patch Changes
|
|
99
|
+
|
|
100
|
+
- @ykurilov/agent-chat-react@0.43.0
|
|
101
|
+
|
|
102
|
+
## 0.42.0
|
|
103
|
+
|
|
104
|
+
### Patch Changes
|
|
105
|
+
|
|
106
|
+
- Updated dependencies [a385276]
|
|
107
|
+
- @ykurilov/agent-chat-react@0.42.0
|
|
108
|
+
|
|
109
|
+
## 0.41.0
|
|
110
|
+
|
|
111
|
+
### Patch Changes
|
|
112
|
+
|
|
113
|
+
- Updated dependencies [9472fcb]
|
|
114
|
+
- @ykurilov/agent-chat-react@0.41.0
|
|
115
|
+
|
|
116
|
+
## 0.40.0
|
|
117
|
+
|
|
118
|
+
### Patch Changes
|
|
119
|
+
|
|
120
|
+
- Updated dependencies [0e7aab3]
|
|
121
|
+
- @ykurilov/agent-chat-react@0.40.0
|
|
122
|
+
|
|
123
|
+
## 0.39.1
|
|
124
|
+
|
|
125
|
+
### Patch Changes
|
|
126
|
+
|
|
127
|
+
- Updated dependencies [5c0f3af]
|
|
128
|
+
- @ykurilov/agent-chat-react@0.39.1
|
|
129
|
+
|
|
130
|
+
## 0.39.0
|
|
131
|
+
|
|
132
|
+
### Patch Changes
|
|
133
|
+
|
|
134
|
+
- Updated dependencies [3b90429]
|
|
135
|
+
- @ykurilov/agent-chat-react@0.39.0
|
|
136
|
+
|
|
137
|
+
## 0.38.0
|
|
138
|
+
|
|
139
|
+
### Patch Changes
|
|
140
|
+
|
|
141
|
+
- @ykurilov/agent-chat-react@0.38.0
|
|
142
|
+
|
|
143
|
+
## 0.37.0
|
|
144
|
+
|
|
145
|
+
### Patch Changes
|
|
146
|
+
|
|
147
|
+
- @ykurilov/agent-chat-react@0.37.0
|
|
148
|
+
|
|
149
|
+
## 0.36.0
|
|
150
|
+
|
|
151
|
+
### Patch Changes
|
|
152
|
+
|
|
153
|
+
- Updated dependencies [3487fb4]
|
|
154
|
+
- Updated dependencies [5f13c74]
|
|
155
|
+
- @ykurilov/agent-chat-react@0.36.0
|
|
156
|
+
|
|
157
|
+
## 0.35.0
|
|
158
|
+
|
|
159
|
+
### Patch Changes
|
|
160
|
+
|
|
161
|
+
- Updated dependencies [6ceee78]
|
|
162
|
+
- @ykurilov/agent-chat-react@0.35.0
|
|
163
|
+
|
|
164
|
+
## 0.34.0
|
|
165
|
+
|
|
166
|
+
### Patch Changes
|
|
167
|
+
|
|
168
|
+
- Updated dependencies [d039ce4]
|
|
169
|
+
- @ykurilov/agent-chat-react@0.34.0
|
|
170
|
+
|
|
171
|
+
## 0.33.0
|
|
172
|
+
|
|
173
|
+
### Patch Changes
|
|
174
|
+
|
|
175
|
+
- @ykurilov/agent-chat-react@0.33.0
|
|
176
|
+
|
|
177
|
+
## 0.32.0
|
|
178
|
+
|
|
179
|
+
### Patch Changes
|
|
180
|
+
|
|
181
|
+
- Updated dependencies [4087a22]
|
|
182
|
+
- @ykurilov/agent-chat-react@0.32.0
|
|
183
|
+
|
|
184
|
+
## 0.31.2
|
|
185
|
+
|
|
186
|
+
### Patch Changes
|
|
187
|
+
|
|
188
|
+
- @ykurilov/agent-chat-react@0.31.2
|
|
189
|
+
|
|
190
|
+
## 0.31.1
|
|
191
|
+
|
|
192
|
+
### Patch Changes
|
|
193
|
+
|
|
194
|
+
- @ykurilov/agent-chat-react@0.31.1
|
|
195
|
+
|
|
196
|
+
## 0.31.0
|
|
197
|
+
|
|
198
|
+
### Patch Changes
|
|
199
|
+
|
|
200
|
+
- @ykurilov/agent-chat-react@0.31.0
|
|
201
|
+
|
|
202
|
+
## 0.30.0
|
|
203
|
+
|
|
204
|
+
### Patch Changes
|
|
205
|
+
|
|
206
|
+
- @ykurilov/agent-chat-react@0.30.0
|
|
207
|
+
|
|
208
|
+
## 0.29.0
|
|
209
|
+
|
|
210
|
+
### Patch Changes
|
|
211
|
+
|
|
212
|
+
- @ykurilov/agent-chat-react@0.29.0
|
|
213
|
+
|
|
214
|
+
## 0.28.0
|
|
215
|
+
|
|
216
|
+
### Patch Changes
|
|
217
|
+
|
|
218
|
+
- Updated dependencies [d63ccaa]
|
|
219
|
+
- Updated dependencies [189cba9]
|
|
220
|
+
- @ykurilov/agent-chat-react@0.28.0
|
|
221
|
+
|
|
222
|
+
## 0.27.0
|
|
223
|
+
|
|
224
|
+
### Patch Changes
|
|
225
|
+
|
|
226
|
+
- Updated dependencies [5e4ed28]
|
|
227
|
+
- Updated dependencies [e0bb519]
|
|
228
|
+
- @ykurilov/agent-chat-react@0.27.0
|
|
229
|
+
|
|
230
|
+
## 0.26.2
|
|
231
|
+
|
|
232
|
+
### Patch Changes
|
|
233
|
+
|
|
234
|
+
- @ykurilov/agent-chat-react@0.26.2
|
|
235
|
+
|
|
236
|
+
## 0.26.1
|
|
237
|
+
|
|
238
|
+
### Patch Changes
|
|
239
|
+
|
|
240
|
+
- Updated dependencies [5b52515]
|
|
241
|
+
- @ykurilov/agent-chat-react@0.26.1
|
|
242
|
+
|
|
243
|
+
## 0.26.0
|
|
244
|
+
|
|
245
|
+
### Patch Changes
|
|
246
|
+
|
|
247
|
+
- @ykurilov/agent-chat-react@0.26.0
|
|
248
|
+
|
|
249
|
+
## 0.25.0
|
|
250
|
+
|
|
251
|
+
### Patch Changes
|
|
252
|
+
|
|
253
|
+
- @ykurilov/agent-chat-react@0.25.0
|
|
254
|
+
|
|
255
|
+
## 0.24.0
|
|
256
|
+
|
|
257
|
+
### Patch Changes
|
|
258
|
+
|
|
259
|
+
- Updated dependencies [9b895cd]
|
|
260
|
+
- @ykurilov/agent-chat-react@0.24.0
|
|
261
|
+
|
|
262
|
+
## 0.23.0
|
|
263
|
+
|
|
264
|
+
### Patch Changes
|
|
265
|
+
|
|
266
|
+
- Updated dependencies [30fb729]
|
|
267
|
+
- @ykurilov/agent-chat-react@0.23.0
|
|
268
|
+
|
|
269
|
+
## 0.22.0
|
|
270
|
+
|
|
271
|
+
### Patch Changes
|
|
272
|
+
|
|
273
|
+
- @ykurilov/agent-chat-react@0.22.0
|
|
274
|
+
|
|
275
|
+
## 0.21.0
|
|
276
|
+
|
|
277
|
+
### Patch Changes
|
|
278
|
+
|
|
279
|
+
- @ykurilov/agent-chat-react@0.21.0
|
|
280
|
+
|
|
281
|
+
## 0.20.0
|
|
282
|
+
|
|
283
|
+
### Patch Changes
|
|
284
|
+
|
|
285
|
+
- Updated dependencies [fd16b01]
|
|
286
|
+
- @ykurilov/agent-chat-react@0.20.0
|
|
287
|
+
|
|
288
|
+
## 0.19.0
|
|
289
|
+
|
|
290
|
+
### Patch Changes
|
|
291
|
+
|
|
292
|
+
- Updated dependencies [aa0fd51]
|
|
293
|
+
- Updated dependencies [6f26b93]
|
|
294
|
+
- @ykurilov/agent-chat-react@0.19.0
|
|
295
|
+
|
|
296
|
+
## 0.18.6
|
|
297
|
+
|
|
298
|
+
### Patch Changes
|
|
299
|
+
|
|
300
|
+
- c688091: Встроенный поиск `searchVideos(videos, query)` экспортируется из корня пакета: приложение со списком роликов в базе зовёт его из `source.search`.
|
|
301
|
+
- @ykurilov/agent-chat-react@0.18.6
|
|
302
|
+
|
|
303
|
+
## 0.18.5
|
|
304
|
+
|
|
305
|
+
### Patch Changes
|
|
306
|
+
|
|
307
|
+
- Updated dependencies [56b9ab6]
|
|
308
|
+
- @ykurilov/agent-chat-react@0.18.5
|
|
309
|
+
|
|
310
|
+
## 0.18.4
|
|
311
|
+
|
|
312
|
+
### Patch Changes
|
|
313
|
+
|
|
314
|
+
- @ykurilov/agent-chat-react@0.18.4
|
|
315
|
+
|
|
316
|
+
## 0.18.3
|
|
317
|
+
|
|
318
|
+
### Patch Changes
|
|
319
|
+
|
|
320
|
+
- Updated dependencies [ce3c26f]
|
|
321
|
+
- @ykurilov/agent-chat-react@0.18.3
|
|
322
|
+
|
|
323
|
+
## 0.18.2
|
|
324
|
+
|
|
325
|
+
### Patch Changes
|
|
326
|
+
|
|
327
|
+
- Updated dependencies [7e7e966]
|
|
328
|
+
- @ykurilov/agent-chat-react@0.18.2
|
|
329
|
+
|
|
330
|
+
## 0.18.1
|
|
331
|
+
|
|
332
|
+
### Patch Changes
|
|
333
|
+
|
|
334
|
+
- Updated dependencies [179ad9d]
|
|
335
|
+
- @ykurilov/agent-chat-react@0.18.1
|
|
336
|
+
|
|
337
|
+
## 0.18.0
|
|
338
|
+
|
|
339
|
+
### Patch Changes
|
|
340
|
+
|
|
341
|
+
- Updated dependencies [43d7ebe]
|
|
342
|
+
- @ykurilov/agent-chat-react@0.18.0
|
|
343
|
+
|
|
344
|
+
## 0.17.0
|
|
345
|
+
|
|
346
|
+
### Minor Changes
|
|
347
|
+
|
|
348
|
+
- e5d0860: Видео от агента: новый пакет `@ykurilov/agent-chat-videos`. Агент находит видео в каталоге приложения (`find_videos`) и
|
|
349
|
+
присылает карточкой (`show_video`), можно с нужной минуты. По нажатию плеер YouTube (`youtube-nocookie.com`) или Kinescope
|
|
350
|
+
играет в ленте, есть «На весь экран». До нажатия к площадкам не уходит ни одного запроса.
|
|
351
|
+
|
|
352
|
+
- сервер: `createVideoLibrary({ videos })` или `({ source: { search, get } })`, `videos.tools(model)` в `agent.tools`;
|
|
353
|
+
- страница: `videos={videosClient}` у `AgentChat`, `ChatWorkspace` и `AgentPanel`;
|
|
354
|
+
- договор скина версии 14: необязательная часть `AgentVideo` (`AgentVideoView`); скин без неё показывает видео ссылкой;
|
|
355
|
+
- надписи `videos.play`, `videos.from`, `videos.fullscreen` и `voice.videoOnScreen` («видео на экране» в голосовом режиме).
|
|
356
|
+
|
|
357
|
+
Подробно: `docs/videos.md`.
|
|
358
|
+
|
|
359
|
+
### Как обновить приложение
|
|
360
|
+
|
|
361
|
+
Обычному приложению делать ничего не нужно: видео включается отдельно, миграций нет.
|
|
362
|
+
|
|
363
|
+
1. Полный словарь надписей, написанный вручную без `{ ...russianLabels }`, — добавьте `voice.videoOnScreen`.
|
|
364
|
+
2. Проверка: `pnpm typecheck`, затем в чате отправить сообщение и получить ответ.
|
|
365
|
+
|
|
366
|
+
### Patch Changes
|
|
367
|
+
|
|
368
|
+
- Updated dependencies [e5d0860]
|
|
369
|
+
- @ykurilov/agent-chat-react@0.17.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yury Kurilov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,40 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ykurilov/agent-chat-videos
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Видео от агента: инструменты `find_videos` и `show_video` поверх каталога приложения (YouTube и Kinescope), карточка
|
|
4
|
+
с плеером в ленте. Отдельное расширение: приложению без видео пакет не нужен.
|
|
5
|
+
|
|
6
|
+
Серверу без экрана React не нужен: `react` и `@ykurilov/agent-chat-react` — необязательные зависимости (peer). Для
|
|
7
|
+
`@ykurilov/agent-chat-videos/react` оба должны стоять в приложении.
|
|
8
|
+
|
|
9
|
+
## Сервер
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createVideoLibrary } from "@ykurilov/agent-chat-videos";
|
|
13
|
+
|
|
14
|
+
const videos = createVideoLibrary({
|
|
15
|
+
videos: [{ id: "intro", title: "Вводный урок", url: "https://youtu.be/aircAruvnKk", durationSeconds: 1153 }],
|
|
16
|
+
// или source: { search(query, { ownerId, chatId }), get(id, { ownerId, chatId }) } — для большой базы
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
createChatHandler({
|
|
20
|
+
…,
|
|
21
|
+
agent: { tools: (model) => ({ ...appTools(model), ...videos.tools(model) }) },
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- Модель ищет через `find_videos` (до 8 результатов, с главами) и показывает через `show_video` по id. Чужой id — отказ.
|
|
26
|
+
- Можно начать с нужной секунды (`startSeconds`), на карточке — «с 3:20».
|
|
27
|
+
- Описания инструментов постоянные: кэш модели не зависит от каталога.
|
|
28
|
+
|
|
29
|
+
## Экран
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { videosClient } from "@ykurilov/agent-chat-videos/react";
|
|
33
|
+
|
|
34
|
+
<AgentChat … videos={videosClient} />
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Скину нужен `AgentVideo` (договор скина, версия 14); в Тандеме он есть. Без него видео показано ссылкой в тексте ответа,
|
|
38
|
+
без `videos` — обычной строкой инструмента. До нажатия к площадкам не уходит ни одного запроса.
|
|
39
|
+
|
|
40
|
+
Подробно — [docs/videos.md](../../docs/videos.md). Живая проверка: `pnpm test:live videos`.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// src/time-label.ts
|
|
2
|
+
var twoDigits = (value) => String(value).padStart(2, "0");
|
|
3
|
+
function timeLabel(seconds) {
|
|
4
|
+
const whole = Math.max(0, Math.floor(seconds));
|
|
5
|
+
const hours = Math.floor(whole / 3600);
|
|
6
|
+
const minutes = Math.floor(whole % 3600 / 60);
|
|
7
|
+
const rest = whole % 60;
|
|
8
|
+
return hours > 0 ? `${hours}:${twoDigits(minutes)}:${twoDigits(rest)}` : `${minutes}:${twoDigits(rest)}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/video-address.ts
|
|
12
|
+
var validCode = {
|
|
13
|
+
youtube: /^[A-Za-z0-9_-]{11}$/,
|
|
14
|
+
kinescope: /^[A-Za-z0-9_-]{4,64}$/
|
|
15
|
+
};
|
|
16
|
+
var kinescopeServicePages = /* @__PURE__ */ new Set([
|
|
17
|
+
"about",
|
|
18
|
+
"account",
|
|
19
|
+
"admin",
|
|
20
|
+
"api",
|
|
21
|
+
"app",
|
|
22
|
+
"assets",
|
|
23
|
+
"auth",
|
|
24
|
+
"blog",
|
|
25
|
+
"careers",
|
|
26
|
+
"cases",
|
|
27
|
+
"company",
|
|
28
|
+
"contacts",
|
|
29
|
+
"dashboard",
|
|
30
|
+
"developers",
|
|
31
|
+
"docs",
|
|
32
|
+
"education",
|
|
33
|
+
"embed",
|
|
34
|
+
"en",
|
|
35
|
+
"enterprise",
|
|
36
|
+
"faq",
|
|
37
|
+
"features",
|
|
38
|
+
"help",
|
|
39
|
+
"home",
|
|
40
|
+
"integrations",
|
|
41
|
+
"jobs",
|
|
42
|
+
"legal",
|
|
43
|
+
"live",
|
|
44
|
+
"login",
|
|
45
|
+
"logout",
|
|
46
|
+
"news",
|
|
47
|
+
"oauth",
|
|
48
|
+
"offer",
|
|
49
|
+
"partners",
|
|
50
|
+
"player",
|
|
51
|
+
"policy",
|
|
52
|
+
"pricing",
|
|
53
|
+
"privacy",
|
|
54
|
+
"product",
|
|
55
|
+
"register",
|
|
56
|
+
"ru",
|
|
57
|
+
"security",
|
|
58
|
+
"settings",
|
|
59
|
+
"signin",
|
|
60
|
+
"signup",
|
|
61
|
+
"solutions",
|
|
62
|
+
"static",
|
|
63
|
+
"status",
|
|
64
|
+
"support",
|
|
65
|
+
"team",
|
|
66
|
+
"terms",
|
|
67
|
+
"upload",
|
|
68
|
+
"video",
|
|
69
|
+
"videos",
|
|
70
|
+
"watch"
|
|
71
|
+
]);
|
|
72
|
+
var youtubeHosts = /* @__PURE__ */ new Set(["youtube.com", "www.youtube.com", "m.youtube.com", "youtube-nocookie.com", "www.youtube-nocookie.com"]);
|
|
73
|
+
var youtubePathPrefixes = /* @__PURE__ */ new Set(["embed", "shorts", "live", "v"]);
|
|
74
|
+
var kinescopeHosts = /* @__PURE__ */ new Set(["kinescope.io", "www.kinescope.io"]);
|
|
75
|
+
function isVideoAddress(value) {
|
|
76
|
+
const address = value;
|
|
77
|
+
if (typeof address?.code !== "string" || address.provider !== "youtube" && address.provider !== "kinescope") return false;
|
|
78
|
+
if (address.provider === "kinescope" && kinescopeServicePages.has(address.code.toLowerCase())) return false;
|
|
79
|
+
return validCode[address.provider].test(address.code);
|
|
80
|
+
}
|
|
81
|
+
var ROOT_CHECK_ORIGIN = "https://x.invalid";
|
|
82
|
+
function isPosterAddress(value) {
|
|
83
|
+
if (typeof value !== "string" || /[\\\s\u0000-\u001f\u007f]|%2f|%5c/i.test(value)) return false;
|
|
84
|
+
try {
|
|
85
|
+
if (value.startsWith("/")) return new URL(value, ROOT_CHECK_ORIGIN).origin === ROOT_CHECK_ORIGIN;
|
|
86
|
+
return new URL(value).protocol === "https:";
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function checked(provider, code) {
|
|
92
|
+
const address = { provider, code: code ?? "" };
|
|
93
|
+
return isVideoAddress(address) ? address : void 0;
|
|
94
|
+
}
|
|
95
|
+
function parseVideoUrl(url) {
|
|
96
|
+
let parsed;
|
|
97
|
+
try {
|
|
98
|
+
parsed = new URL(url);
|
|
99
|
+
} catch {
|
|
100
|
+
return void 0;
|
|
101
|
+
}
|
|
102
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return void 0;
|
|
103
|
+
const host = parsed.hostname.toLowerCase();
|
|
104
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
105
|
+
if (host === "youtu.be") return segments.length === 1 ? checked("youtube", segments[0]) : void 0;
|
|
106
|
+
if (youtubeHosts.has(host)) {
|
|
107
|
+
if (segments.length === 1 && segments[0] === "watch") return checked("youtube", parsed.searchParams.get("v"));
|
|
108
|
+
if (segments.length === 2 && youtubePathPrefixes.has(segments[0])) return checked("youtube", segments[1]);
|
|
109
|
+
return void 0;
|
|
110
|
+
}
|
|
111
|
+
if (kinescopeHosts.has(host)) {
|
|
112
|
+
if (segments.length === 1 && segments[0] !== "embed") return checked("kinescope", segments[0]);
|
|
113
|
+
if (segments.length === 2 && segments[0] === "embed") return checked("kinescope", segments[1]);
|
|
114
|
+
}
|
|
115
|
+
return void 0;
|
|
116
|
+
}
|
|
117
|
+
var startSecond = (startSeconds) => startSeconds !== void 0 && Number.isInteger(startSeconds) && startSeconds > 0 ? startSeconds : void 0;
|
|
118
|
+
function embedUrl(address, startSeconds) {
|
|
119
|
+
const start = startSecond(startSeconds);
|
|
120
|
+
if (address.provider === "youtube") {
|
|
121
|
+
return `https://www.youtube-nocookie.com/embed/${address.code}?autoplay=1&playsinline=1&rel=0${start ? `&start=${start}` : ""}`;
|
|
122
|
+
}
|
|
123
|
+
return `https://kinescope.io/embed/${address.code}?autoplay=1&playsinline=1${start ? `&t=${start}` : ""}`;
|
|
124
|
+
}
|
|
125
|
+
function watchUrl(address, startSeconds) {
|
|
126
|
+
const start = startSecond(startSeconds);
|
|
127
|
+
if (address.provider === "youtube") return `https://www.youtube.com/watch?v=${address.code}${start ? `&t=${start}s` : ""}`;
|
|
128
|
+
return `https://kinescope.io/${address.code}${start ? `?t=${start}` : ""}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export {
|
|
132
|
+
timeLabel,
|
|
133
|
+
isVideoAddress,
|
|
134
|
+
isPosterAddress,
|
|
135
|
+
parseVideoUrl,
|
|
136
|
+
embedUrl,
|
|
137
|
+
watchUrl
|
|
138
|
+
};
|
|
139
|
+
//# sourceMappingURL=chunk-UROHGMNR.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/time-label.ts","../src/video-address.ts"],"sourcesContent":["const twoDigits = (value: number) => String(value).padStart(2, \"0\");\n\n/** Секунды словами часов: 200 → «3:20», 3725 → «1:02:05». */\nexport function timeLabel(seconds: number): string {\n const whole = Math.max(0, Math.floor(seconds));\n const hours = Math.floor(whole / 3600);\n const minutes = Math.floor((whole % 3600) / 60);\n const rest = whole % 60;\n return hours > 0 ? `${hours}:${twoDigits(minutes)}:${twoDigits(rest)}` : `${minutes}:${twoDigits(rest)}`;\n}\n","// Адрес видео — площадка и код. Из них, а не из ссылки целиком, собираются адреса плеера и страницы:\n// подделанная запись не превратится в чужой iframe.\n\nexport type VideoProvider = \"youtube\" | \"kinescope\";\n\nexport interface VideoAddress {\n provider: VideoProvider;\n code: string;\n}\n\nconst validCode: Record<VideoProvider, RegExp> = {\n youtube: /^[A-Za-z0-9_-]{11}$/,\n kinescope: /^[A-Za-z0-9_-]{4,64}$/,\n};\n\n/** Служебные страницы kinescope.io — по форме похожи на код видео, но видео не являются. */\nconst kinescopeServicePages = new Set([\n \"about\", \"account\", \"admin\", \"api\", \"app\", \"assets\", \"auth\", \"blog\", \"careers\", \"cases\", \"company\", \"contacts\", \"dashboard\",\n \"developers\", \"docs\", \"education\", \"embed\", \"en\", \"enterprise\", \"faq\", \"features\", \"help\", \"home\", \"integrations\", \"jobs\",\n \"legal\", \"live\", \"login\", \"logout\", \"news\", \"oauth\", \"offer\", \"partners\", \"player\", \"policy\", \"pricing\", \"privacy\", \"product\",\n \"register\", \"ru\", \"security\", \"settings\", \"signin\", \"signup\", \"solutions\", \"static\", \"status\", \"support\", \"team\", \"terms\",\n \"upload\", \"video\", \"videos\", \"watch\",\n]);\n\nconst youtubeHosts = new Set([\"youtube.com\", \"www.youtube.com\", \"m.youtube.com\", \"youtube-nocookie.com\", \"www.youtube-nocookie.com\"]);\nconst youtubePathPrefixes = new Set([\"embed\", \"shorts\", \"live\", \"v\"]);\nconst kinescopeHosts = new Set([\"kinescope.io\", \"www.kinescope.io\"]);\n\n/** Площадка известна, код — только из допустимых знаков. */\nexport function isVideoAddress(value: unknown): value is VideoAddress {\n const address = value as Partial<VideoAddress> | null;\n if (typeof address?.code !== \"string\" || (address.provider !== \"youtube\" && address.provider !== \"kinescope\")) return false;\n if (address.provider === \"kinescope\" && kinescopeServicePages.has(address.code.toLowerCase())) return false;\n return validCode[address.provider].test(address.code);\n}\n\nconst ROOT_CHECK_ORIGIN = \"https://x.invalid\";\n\n/**\n * Обложка — `https://…` или путь от корня своего сайта. Браузер читает `/\\evil`, `/<таб>/evil` как `//evil` — чужой хост,\n * поэтому путь разбирается как адрес и обязан остаться на своём хосте; `\\`, пробелы, управляющие знаки и закодированные\n * косые не допускаются вовсе.\n */\nexport function isPosterAddress(value: unknown): value is string {\n if (typeof value !== \"string\" || /[\\\\\\s\\u0000-\\u001f\\u007f]|%2f|%5c/i.test(value)) return false;\n try {\n if (value.startsWith(\"/\")) return new URL(value, ROOT_CHECK_ORIGIN).origin === ROOT_CHECK_ORIGIN;\n return new URL(value).protocol === \"https:\";\n } catch {\n return false;\n }\n}\n\nfunction checked(provider: VideoProvider, code: string | null | undefined): VideoAddress | undefined {\n const address = { provider, code: code ?? \"\" };\n return isVideoAddress(address) ? address : undefined;\n}\n\n/** Ссылка YouTube (watch, youtu.be, embed, shorts, live) или Kinescope (`/<код>`, `/embed/<код>`) → площадка и код. */\nexport function parseVideoUrl(url: string): VideoAddress | undefined {\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n return undefined;\n }\n if (parsed.protocol !== \"https:\" && parsed.protocol !== \"http:\") return undefined;\n const host = parsed.hostname.toLowerCase();\n const segments = parsed.pathname.split(\"/\").filter(Boolean);\n\n if (host === \"youtu.be\") return segments.length === 1 ? checked(\"youtube\", segments[0]) : undefined;\n if (youtubeHosts.has(host)) {\n if (segments.length === 1 && segments[0] === \"watch\") return checked(\"youtube\", parsed.searchParams.get(\"v\"));\n if (segments.length === 2 && youtubePathPrefixes.has(segments[0]!)) return checked(\"youtube\", segments[1]);\n return undefined;\n }\n if (kinescopeHosts.has(host)) {\n if (segments.length === 1 && segments[0] !== \"embed\") return checked(\"kinescope\", segments[0]);\n if (segments.length === 2 && segments[0] === \"embed\") return checked(\"kinescope\", segments[1]);\n }\n return undefined;\n}\n\nconst startSecond = (startSeconds: number | undefined) =>\n startSeconds !== undefined && Number.isInteger(startSeconds) && startSeconds > 0 ? startSeconds : undefined;\n\n/** Адрес встроенного плеера: YouTube — с youtube-nocookie.com; оба с автозапуском (грузится по нажатию) и стартом. */\nexport function embedUrl(address: VideoAddress, startSeconds?: number): string {\n const start = startSecond(startSeconds);\n if (address.provider === \"youtube\") {\n return `https://www.youtube-nocookie.com/embed/${address.code}?autoplay=1&playsinline=1&rel=0${start ? `&start=${start}` : \"\"}`;\n }\n return `https://kinescope.io/embed/${address.code}?autoplay=1&playsinline=1${start ? `&t=${start}` : \"\"}`;\n}\n\n/** Адрес страницы видео на площадке — для ссылки, когда скин не умеет карточку. */\nexport function watchUrl(address: VideoAddress, startSeconds?: number): string {\n const start = startSecond(startSeconds);\n if (address.provider === \"youtube\") return `https://www.youtube.com/watch?v=${address.code}${start ? `&t=${start}s` : \"\"}`;\n return `https://kinescope.io/${address.code}${start ? `?t=${start}` : \"\"}`;\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import {
|
|
2
|
+
embedUrl,
|
|
3
|
+
isPosterAddress,
|
|
4
|
+
isVideoAddress,
|
|
5
|
+
parseVideoUrl,
|
|
6
|
+
timeLabel,
|
|
7
|
+
watchUrl
|
|
8
|
+
} from "./chunk-UROHGMNR.js";
|
|
9
|
+
|
|
10
|
+
// src/create-video-library.ts
|
|
11
|
+
import { tool } from "ai";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
|
|
14
|
+
// src/video-catalog.ts
|
|
15
|
+
var MAX_ID_LENGTH = 200;
|
|
16
|
+
var MAX_FOUND = 8;
|
|
17
|
+
var isText = (value) => typeof value === "string" && value.trim() !== "";
|
|
18
|
+
function checkVideoEntry(entry) {
|
|
19
|
+
if (typeof entry?.id !== "string" || entry.id === "" || entry.id.length > MAX_ID_LENGTH) return "id \u0434\u043E\u043B\u0436\u0435\u043D \u0431\u044B\u0442\u044C \u0441\u0442\u0440\u043E\u043A\u043E\u0439 \u043E\u0442 1 \u0434\u043E 200 \u0437\u043D\u0430\u043A\u043E\u0432";
|
|
20
|
+
if (!isText(entry.title)) return "\u043F\u0443\u0441\u0442\u043E\u0435 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435";
|
|
21
|
+
if (typeof entry.url !== "string" || !parseVideoUrl(entry.url)) return "\u0441\u0441\u044B\u043B\u043A\u0430 \u043D\u0435 \u043D\u0430 YouTube \u0438 \u043D\u0435 \u043D\u0430 Kinescope";
|
|
22
|
+
if (entry.description !== void 0 && typeof entry.description !== "string") return "\u043E\u043F\u0438\u0441\u0430\u043D\u0438\u0435 \u043D\u0435 \u0441\u0442\u0440\u043E\u043A\u0430";
|
|
23
|
+
const duration = entry.durationSeconds;
|
|
24
|
+
if (duration !== void 0 && (!Number.isInteger(duration) || duration <= 0)) return "\u0434\u043B\u0438\u0442\u0435\u043B\u044C\u043D\u043E\u0441\u0442\u044C \u0434\u043E\u043B\u0436\u043D\u0430 \u0431\u044B\u0442\u044C \u0446\u0435\u043B\u044B\u043C \u0447\u0438\u0441\u043B\u043E\u043C \u0441\u0435\u043A\u0443\u043D\u0434 \u0431\u043E\u043B\u044C\u0448\u0435 \u043D\u0443\u043B\u044F";
|
|
25
|
+
if (entry.poster !== void 0 && !isPosterAddress(entry.poster)) {
|
|
26
|
+
return "\u043E\u0431\u043B\u043E\u0436\u043A\u0430 \u0434\u043E\u043B\u0436\u043D\u0430 \u0431\u044B\u0442\u044C \u0430\u0434\u0440\u0435\u0441\u043E\u043C https:// \u0438\u043B\u0438 \u043F\u0443\u0442\u0451\u043C \u043E\u0442 \u043A\u043E\u0440\u043D\u044F \u0441\u0430\u0439\u0442\u0430 \xAB/\u2026\xBB";
|
|
27
|
+
}
|
|
28
|
+
if (entry.chapters !== void 0) {
|
|
29
|
+
if (!Array.isArray(entry.chapters)) return "\u0433\u043B\u0430\u0432\u044B \u0434\u043E\u043B\u0436\u043D\u044B \u0431\u044B\u0442\u044C \u0441\u043F\u0438\u0441\u043A\u043E\u043C";
|
|
30
|
+
for (const chapter of entry.chapters) {
|
|
31
|
+
if (!isText(chapter?.title)) return "\u0433\u043B\u0430\u0432\u0430 \u0431\u0435\u0437 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u044F";
|
|
32
|
+
const start = chapter.startSeconds;
|
|
33
|
+
if (!Number.isInteger(start) || start < 0) return "\u043D\u0430\u0447\u0430\u043B\u043E \u0433\u043B\u0430\u0432\u044B \u0434\u043E\u043B\u0436\u043D\u043E \u0431\u044B\u0442\u044C \u0446\u0435\u043B\u044B\u043C \u0447\u0438\u0441\u043B\u043E\u043C \u0441\u0435\u043A\u0443\u043D\u0434 \u043E\u0442 \u043D\u0443\u043B\u044F";
|
|
34
|
+
if (duration !== void 0 && start >= duration) return "\u0433\u043B\u0430\u0432\u0430 \u043D\u0430\u0447\u0438\u043D\u0430\u0435\u0442\u0441\u044F \u0437\u0430 \u043A\u043E\u043D\u0446\u043E\u043C \u0432\u0438\u0434\u0435\u043E";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return void 0;
|
|
38
|
+
}
|
|
39
|
+
function checkVideoCatalog(videos) {
|
|
40
|
+
const seen = /* @__PURE__ */ new Set();
|
|
41
|
+
videos.forEach((video, index) => {
|
|
42
|
+
const reason = checkVideoEntry(video);
|
|
43
|
+
const name = typeof video?.id === "string" && video.id !== "" ? `\xAB${video.id}\xBB` : `\u2116${index + 1}`;
|
|
44
|
+
if (reason) throw new Error(`\u041A\u0430\u0442\u0430\u043B\u043E\u0433 \u0432\u0438\u0434\u0435\u043E: \u0437\u0430\u043F\u0438\u0441\u044C ${name} \u2014 ${reason}.`);
|
|
45
|
+
if (seen.has(video.id)) throw new Error(`\u041A\u0430\u0442\u0430\u043B\u043E\u0433 \u0432\u0438\u0434\u0435\u043E: id ${name} \u043F\u043E\u0432\u0442\u043E\u0440\u044F\u0435\u0442\u0441\u044F.`);
|
|
46
|
+
seen.add(video.id);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
var serviceWords = new Set(
|
|
50
|
+
"\u0432\u0438\u0434\u0435\u043E \u0440\u043E\u043B\u0438\u043A \u0440\u043E\u043B\u0438\u043A\u0430 \u0440\u043E\u043B\u0438\u043A\u0438 \u0440\u043E\u043B\u0438\u043A\u043E\u0432 \u0443\u0440\u043E\u043A \u0443\u0440\u043E\u043A\u0430 \u0443\u0440\u043E\u043A\u0438 \u0443\u0440\u043E\u043A\u0443 \u0443\u0440\u043E\u043A\u043E\u043C \u0443\u0440\u043E\u043A\u043E\u0432 \u043B\u0435\u043A\u0446\u0438\u044F \u043B\u0435\u043A\u0446\u0438\u0438 \u043B\u0435\u043A\u0446\u0438\u044E \u043B\u0435\u043A\u0446\u0438\u0439 \u043F\u0440\u043E \u043A\u0430\u043A \u0447\u0442\u043E \u044D\u0442\u043E \u0434\u043B\u044F \u0438\u043B\u0438 \u043D\u0430 \u043F\u043E \u0438\u0437 \u043E\u0442".split(" ")
|
|
51
|
+
);
|
|
52
|
+
var words = (text) => text.toLowerCase().replaceAll("\u0451", "\u0435").split(/[^\p{L}\p{N}]+/u).filter((word) => word.length >= 2);
|
|
53
|
+
var stem = (word) => word.length > 5 ? word.slice(0, 5) : word.length >= 4 ? word.slice(0, -1) : word;
|
|
54
|
+
var weights = { title: 3, chapters: 2, description: 1 };
|
|
55
|
+
function score(video, queryStems) {
|
|
56
|
+
const fields = {
|
|
57
|
+
title: words(video.title),
|
|
58
|
+
chapters: (video.chapters ?? []).flatMap((chapter) => words(chapter.title)),
|
|
59
|
+
description: words(video.description ?? "")
|
|
60
|
+
};
|
|
61
|
+
let total = 0;
|
|
62
|
+
for (const queryStem of queryStems) {
|
|
63
|
+
for (const field of ["title", "chapters", "description"]) {
|
|
64
|
+
if (fields[field].some((word) => word.startsWith(queryStem))) total += weights[field];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return total;
|
|
68
|
+
}
|
|
69
|
+
function searchVideos(videos, query) {
|
|
70
|
+
const all = words(query);
|
|
71
|
+
const meaningful = all.filter((word) => !serviceWords.has(word));
|
|
72
|
+
const queryStems = [...new Set((meaningful.length > 0 ? meaningful : all).map(stem))];
|
|
73
|
+
if (queryStems.length === 0) return [];
|
|
74
|
+
return videos.map((video, index) => ({ video, index, score: score(video, queryStems) })).filter((found) => found.score > 0).sort((a, b) => b.score - a.score || a.index - b.index).slice(0, MAX_FOUND).map((found) => found.video);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/create-video-library.ts
|
|
78
|
+
var MAX_DESCRIPTION = 300;
|
|
79
|
+
var NOT_IN_CATALOG = "\u0422\u0430\u043A\u043E\u0433\u043E \u0432\u0438\u0434\u0435\u043E \u043D\u0435\u0442 \u0432 \u043A\u0430\u0442\u0430\u043B\u043E\u0433\u0435. \u041D\u0430\u0439\u0434\u0438 \u0435\u0433\u043E \u0447\u0435\u0440\u0435\u0437 find_videos \u0438 \u043F\u0435\u0440\u0435\u0434\u0430\u0439 id \u043E\u0442\u0442\u0443\u0434\u0430.";
|
|
80
|
+
var CATALOG_UNAVAILABLE = "\u041A\u0430\u0442\u0430\u043B\u043E\u0433 \u0432\u0438\u0434\u0435\u043E \u0441\u0435\u0439\u0447\u0430\u0441 \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D. \u0421\u043A\u0430\u0436\u0438 \u043E\u0431 \u044D\u0442\u043E\u043C \u0447\u0435\u043B\u043E\u0432\u0435\u043A\u0443 \u0438 \u043D\u0435 \u043F\u0440\u043E\u0431\u0443\u0439 \u0441\u043D\u043E\u0432\u0430 \u0432 \u044D\u0442\u043E\u043C \u043E\u0442\u0432\u0435\u0442\u0435.";
|
|
81
|
+
var CATALOG_UNAVAILABLE_MARK = /* @__PURE__ */ Symbol("\u043A\u0430\u0442\u0430\u043B\u043E\u0433 \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D");
|
|
82
|
+
var NOTHING_FOUND = "\u041D\u0438\u0447\u0435\u0433\u043E \u043D\u0435 \u043D\u0430\u0448\u043B\u043E\u0441\u044C. \u041F\u043E\u043F\u0440\u043E\u0431\u0443\u0439 \u0434\u0440\u0443\u0433\u0438\u0435 \u0441\u043B\u043E\u0432\u0430: \u0442\u0435\u043C\u0443 \u0438\u043B\u0438 \u043A\u043B\u044E\u0447\u0435\u0432\u043E\u0435 \u043F\u043E\u043D\u044F\u0442\u0438\u0435, \u0430 \u043D\u0435 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435 \u0446\u0435\u043B\u0438\u043A\u043E\u043C.";
|
|
83
|
+
var findVideosDescription = "\u0418\u0449\u0435\u0442 \u0432\u0438\u0434\u0435\u043E \u0432 \u043A\u0430\u0442\u0430\u043B\u043E\u0433\u0435 \u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u044F: \u0443\u0440\u043E\u043A\u0438, \u043B\u0435\u043A\u0446\u0438\u0438, \u0437\u0430\u043F\u0438\u0441\u0438. \u041E\u0442\u0434\u0430\u0451\u0442 \u0434\u043E 8 \u043F\u043E\u0434\u0445\u043E\u0434\u044F\u0449\u0438\u0445 \u0441 id, \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435\u043C, \u043E\u043F\u0438\u0441\u0430\u043D\u0438\u0435\u043C, \u0434\u043B\u0438\u0442\u0435\u043B\u044C\u043D\u043E\u0441\u0442\u044C\u044E \u0438 \u0433\u043B\u0430\u0432\u0430\u043C\u0438. \u0412\u044B\u0437\u044B\u0432\u0430\u0439, \u043A\u043E\u0433\u0434\u0430 \u0447\u0435\u043B\u043E\u0432\u0435\u043A \u043F\u0440\u043E\u0441\u0438\u0442 \u0432\u0438\u0434\u0435\u043E, \u0443\u0440\u043E\u043A \u0438\u043B\u0438 \u0437\u0430\u043F\u0438\u0441\u044C \u043F\u043E \u0442\u0435\u043C\u0435, \u0438 \u043F\u0435\u0440\u0435\u0434 show_video.";
|
|
84
|
+
var showVideoDescription = "\u041F\u043E\u043A\u0430\u0437\u044B\u0432\u0430\u0435\u0442 \u0447\u0435\u043B\u043E\u0432\u0435\u043A\u0443 \u0432\u0438\u0434\u0435\u043E \u0438\u0437 \u043A\u0430\u0442\u0430\u043B\u043E\u0433\u0430 \u043A\u0430\u0440\u0442\u043E\u0447\u043A\u043E\u0439 \u0441 \u043F\u043B\u0435\u0435\u0440\u043E\u043C. \u041F\u0435\u0440\u0435\u0434\u0430\u0439 id \u0438\u0437 \u0440\u0435\u0437\u0443\u043B\u044C\u0442\u0430\u0442\u0430 find_videos \u2014 \u0434\u0440\u0443\u0433\u0438\u0435 id \u043D\u0435 \u043F\u043E\u0434\u043E\u0439\u0434\u0443\u0442. startSeconds \u2014 \u0441 \u043A\u0430\u043A\u043E\u0439 \u0441\u0435\u043A\u0443\u043D\u0434\u044B \u043D\u0430\u0447\u0430\u0442\u044C, \u0435\u0441\u043B\u0438 \u043D\u0443\u0436\u043D\u0430 \u043A\u043E\u043D\u043A\u0440\u0435\u0442\u043D\u0430\u044F \u0433\u043B\u0430\u0432\u0430 (\u0432\u043E\u0437\u044C\u043C\u0438 \xAB\u0441\u0435\u043A\u0443\u043D\u0434\u0430\xBB \u0438\u0437 \u0433\u043B\u0430\u0432). \u0421\u0441\u044B\u043B\u043A\u0443 \u043D\u0430 \u0432\u0438\u0434\u0435\u043E \u0432 \u043E\u0442\u0432\u0435\u0442 \u043D\u0435 \u0432\u0441\u0442\u0430\u0432\u043B\u044F\u0439: \u043A\u0430\u0440\u0442\u043E\u0447\u043A\u0430 \u0443\u0436\u0435 \u043D\u0430 \u044D\u043A\u0440\u0430\u043D\u0435. \u041D\u0430\u0437\u043E\u0432\u0438 \u0432\u0438\u0434\u0435\u043E \u043E\u0434\u043D\u043E\u0439 \u0444\u0440\u0430\u0437\u043E\u0439.";
|
|
85
|
+
var findVideosInput = z.object({
|
|
86
|
+
query: z.string().min(1).max(200).describe("\u0421\u043B\u043E\u0432\u0430 \u0434\u043B\u044F \u043F\u043E\u0438\u0441\u043A\u0430: \u0442\u0435\u043C\u0430 \u0438\u043B\u0438 \u043F\u043E\u043D\u044F\u0442\u0438\u0435, \u043E \u043A\u043E\u0442\u043E\u0440\u043E\u043C \u0432\u0438\u0434\u0435\u043E.")
|
|
87
|
+
});
|
|
88
|
+
var showVideoInput = z.object({
|
|
89
|
+
id: z.string().min(1).max(200).describe("id \u0432\u0438\u0434\u0435\u043E \u0438\u0437 \u0440\u0435\u0437\u0443\u043B\u044C\u0442\u0430\u0442\u0430 find_videos."),
|
|
90
|
+
startSeconds: z.number().int().min(0).optional().describe("\u0421 \u043A\u0430\u043A\u043E\u0439 \u0441\u0435\u043A\u0443\u043D\u0434\u044B \u043D\u0430\u0447\u0430\u0442\u044C. \u041D\u0435 \u0443\u043A\u0430\u0437\u044B\u0432\u0430\u0439, \u0447\u0442\u043E\u0431\u044B \u043D\u0430\u0447\u0430\u0442\u044C \u0441 \u043D\u0430\u0447\u0430\u043B\u0430.")
|
|
91
|
+
});
|
|
92
|
+
function forModel(video) {
|
|
93
|
+
const description = video.description?.trim();
|
|
94
|
+
return {
|
|
95
|
+
id: video.id,
|
|
96
|
+
\u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435: video.title,
|
|
97
|
+
...description && { \u043E\u043F\u0438\u0441\u0430\u043D\u0438\u0435: description.length > MAX_DESCRIPTION ? `${description.slice(0, MAX_DESCRIPTION - 1)}\u2026` : description },
|
|
98
|
+
...video.durationSeconds !== void 0 && { \u0434\u043B\u0438\u0442\u0435\u043B\u044C\u043D\u043E\u0441\u0442\u044C: timeLabel(video.durationSeconds) },
|
|
99
|
+
...video.chapters?.length && {
|
|
100
|
+
\u0433\u043B\u0430\u0432\u044B: video.chapters.map((chapter) => ({ \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435: chapter.title, \u043D\u0430\u0447\u0430\u043B\u043E: timeLabel(chapter.startSeconds), \u0441\u0435\u043A\u0443\u043D\u0434\u0430: chapter.startSeconds }))
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function shown(video, startSeconds) {
|
|
105
|
+
const address = parseVideoUrl(video.url);
|
|
106
|
+
return {
|
|
107
|
+
\u0432\u0438\u0434\u0435\u043E: {
|
|
108
|
+
id: video.id,
|
|
109
|
+
\u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435: video.title,
|
|
110
|
+
\u043F\u043B\u043E\u0449\u0430\u0434\u043A\u0430: address.provider,
|
|
111
|
+
\u043A\u043E\u0434: address.code,
|
|
112
|
+
...video.durationSeconds !== void 0 && { \u0434\u043B\u0438\u0442\u0435\u043B\u044C\u043D\u043E\u0441\u0442\u044C: video.durationSeconds },
|
|
113
|
+
...video.poster !== void 0 && { \u043E\u0431\u043B\u043E\u0436\u043A\u0430: video.poster },
|
|
114
|
+
...startSeconds && { \u043D\u0430\u0447\u0430\u043B\u043E: startSeconds }
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
var shortId = (id) => typeof id === "string" ? id.slice(0, 200) : "(\u0431\u0435\u0437 id)";
|
|
119
|
+
function createVideoLibrary(options) {
|
|
120
|
+
const logError = options.logError ?? ((message) => console.error(message));
|
|
121
|
+
const catalog = options.videos ? [...options.videos] : void 0;
|
|
122
|
+
if (catalog) checkVideoCatalog(catalog);
|
|
123
|
+
const byId = new Map(catalog?.map((video) => [video.id, video]));
|
|
124
|
+
const usable = (video) => {
|
|
125
|
+
if (video === void 0 || video === null) return false;
|
|
126
|
+
const reason = checkVideoEntry(video);
|
|
127
|
+
if (reason) logError(`\u0412\u0438\u0434\u0435\u043E \xAB${shortId(video?.id)}\xBB \u0438\u0437 \u043A\u0430\u0442\u0430\u043B\u043E\u0433\u0430 \u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u044F \u043F\u0440\u043E\u043F\u0443\u0449\u0435\u043D\u043E: ${reason}.`);
|
|
128
|
+
return !reason;
|
|
129
|
+
};
|
|
130
|
+
const unavailable = (what, reason) => {
|
|
131
|
+
logError(`\u041A\u0430\u0442\u0430\u043B\u043E\u0433 \u0432\u0438\u0434\u0435\u043E \u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u044F: ${what} \u2014 ${reason instanceof Error ? reason.message : String(reason)}.`);
|
|
132
|
+
return CATALOG_UNAVAILABLE_MARK;
|
|
133
|
+
};
|
|
134
|
+
async function find(query, context) {
|
|
135
|
+
if (catalog) return searchVideos(catalog, query);
|
|
136
|
+
let found;
|
|
137
|
+
try {
|
|
138
|
+
found = await options.source.search(query, context);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
return unavailable("search \u0443\u043F\u0430\u043B", error);
|
|
141
|
+
}
|
|
142
|
+
if (!Array.isArray(found)) return unavailable("search \u0432\u0435\u0440\u043D\u0443\u043B \u043D\u0435 \u0441\u043F\u0438\u0441\u043E\u043A", found === null ? "null" : typeof found);
|
|
143
|
+
return found.filter(usable).slice(0, MAX_FOUND);
|
|
144
|
+
}
|
|
145
|
+
async function get(id, context) {
|
|
146
|
+
if (catalog) return byId.get(id);
|
|
147
|
+
let video;
|
|
148
|
+
try {
|
|
149
|
+
video = await options.source.get(id, context);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return unavailable("get \u0443\u043F\u0430\u043B", error);
|
|
152
|
+
}
|
|
153
|
+
return usable(video) && video.id === id ? video : void 0;
|
|
154
|
+
}
|
|
155
|
+
const findVideos = (context) => tool({
|
|
156
|
+
description: findVideosDescription,
|
|
157
|
+
inputSchema: findVideosInput,
|
|
158
|
+
execute: async ({ query }) => {
|
|
159
|
+
const result = await find(query, context);
|
|
160
|
+
if (result === CATALOG_UNAVAILABLE_MARK) return { \u043E\u0442\u043A\u0430\u0437: CATALOG_UNAVAILABLE };
|
|
161
|
+
const found = result.map(forModel);
|
|
162
|
+
return found.length > 0 ? { \u043D\u0430\u0439\u0434\u0435\u043D\u043E: found } : { \u043D\u0430\u0439\u0434\u0435\u043D\u043E: [], \u0441\u043E\u0432\u0435\u0442: NOTHING_FOUND };
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
const showVideo = (context) => tool({
|
|
166
|
+
description: showVideoDescription,
|
|
167
|
+
inputSchema: showVideoInput,
|
|
168
|
+
execute: async ({ id, startSeconds }) => {
|
|
169
|
+
const video = await get(id, context);
|
|
170
|
+
if (video === CATALOG_UNAVAILABLE_MARK) return { \u043E\u0442\u043A\u0430\u0437: CATALOG_UNAVAILABLE };
|
|
171
|
+
if (!video) return { \u043E\u0442\u043A\u0430\u0437: NOT_IN_CATALOG };
|
|
172
|
+
if (startSeconds !== void 0 && video.durationSeconds !== void 0 && startSeconds >= video.durationSeconds) {
|
|
173
|
+
return { \u043E\u0442\u043A\u0430\u0437: `\u041D\u0430\u0447\u0430\u043B\u043E ${timeLabel(startSeconds)} \u0437\u0430 \u043A\u043E\u043D\u0446\u043E\u043C \u0432\u0438\u0434\u0435\u043E: \u043E\u043D\u043E \u0434\u043B\u0438\u0442\u0441\u044F ${timeLabel(video.durationSeconds)}.` };
|
|
174
|
+
}
|
|
175
|
+
return shown(video, startSeconds);
|
|
176
|
+
},
|
|
177
|
+
// Модели — коротко, что показано; карточке — весь результат.
|
|
178
|
+
toModelOutput: ({ output }) => {
|
|
179
|
+
if (output && "\u0432\u0438\u0434\u0435\u043E" in output) {
|
|
180
|
+
const { \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435, \u043D\u0430\u0447\u0430\u043B\u043E } = output.\u0432\u0438\u0434\u0435\u043E;
|
|
181
|
+
return { type: "json", value: { \u043F\u043E\u043A\u0430\u0437\u0430\u043D\u043E: \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435, ...\u043D\u0430\u0447\u0430\u043B\u043E && { \u0441: timeLabel(\u043D\u0430\u0447\u0430\u043B\u043E) } } };
|
|
182
|
+
}
|
|
183
|
+
return { type: "json", value: { \u043E\u0442\u043A\u0430\u0437: output?.\u043E\u0442\u043A\u0430\u0437 ?? NOT_IN_CATALOG } };
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
return {
|
|
187
|
+
tools(model) {
|
|
188
|
+
if (!model.ownerId || !model.chatId) return {};
|
|
189
|
+
const context = { ownerId: model.ownerId, chatId: model.chatId };
|
|
190
|
+
return { find_videos: findVideos(context), show_video: showVideo(context) };
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
export {
|
|
195
|
+
createVideoLibrary,
|
|
196
|
+
embedUrl,
|
|
197
|
+
isVideoAddress,
|
|
198
|
+
parseVideoUrl,
|
|
199
|
+
searchVideos,
|
|
200
|
+
timeLabel,
|
|
201
|
+
watchUrl
|
|
202
|
+
};
|
|
203
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/create-video-library.ts","../src/video-catalog.ts"],"sourcesContent":["import { tool } from \"ai\";\nimport type { ToolSet } from \"ai\";\nimport { z } from \"zod\";\nimport { timeLabel } from \"./time-label\";\nimport { parseVideoUrl } from \"./video-address\";\nimport type { VideoProvider } from \"./video-address\";\nimport { MAX_FOUND, checkVideoCatalog, checkVideoEntry, searchVideos } from \"./video-catalog\";\nimport type { VideoEntry } from \"./video-catalog\";\n\n/** Кто спрашивает: по нему функции приложения могут учитывать права человека. */\nexport interface VideoContext {\n ownerId: string;\n chatId: string;\n}\n\n/** Каталог функциями — для большой базы или своего поиска. Записи проверяются на каждом вызове. */\nexport interface VideoSource {\n search(query: string, context: VideoContext): VideoEntry[] | Promise<VideoEntry[]>;\n get(id: string, context: VideoContext): VideoEntry | undefined | Promise<VideoEntry | undefined>;\n}\n\nexport type CreateVideoLibraryOptions = ({ videos: VideoEntry[]; source?: never } | { source: VideoSource; videos?: never }) & {\n /** Куда писать о негодных записях из функций приложения. По умолчанию — `console.error`. */\n logError?: (message: string) => void;\n};\n\n/** Выбранная модель хода: ядро передаёт в `agent.tools(model)` и того, чей ход, и чат. */\nexport interface VideoLibraryModel {\n ownerId?: string;\n chatId?: string;\n}\n\nexport interface VideoLibrary {\n /** `{ find_videos, show_video }` для `agent.tools`; без `ownerId` и `chatId` — пустой набор. */\n tools(model: VideoLibraryModel): ToolSet;\n}\n\n/** Результат `show_video`: хранится в сообщении и целиком нужен карточке — даже если видео потом убрали из каталога. */\nexport type ShownVideo =\n | {\n видео: { id: string; название: string; площадка: VideoProvider; код: string; длительность?: number; обложка?: string; начало?: number };\n }\n | { отказ: string };\n\nconst MAX_DESCRIPTION = 300;\nconst NOT_IN_CATALOG = \"Такого видео нет в каталоге. Найди его через find_videos и передай id оттуда.\";\nconst CATALOG_UNAVAILABLE = \"Каталог видео сейчас недоступен. Скажи об этом человеку и не пробуй снова в этом ответе.\";\nconst CATALOG_UNAVAILABLE_MARK: unique symbol = Symbol(\"каталог недоступен\");\nconst NOTHING_FOUND = \"Ничего не нашлось. Попробуй другие слова: тему или ключевое понятие, а не название целиком.\";\n\n// Описания и схемы постоянные при любом каталоге: начало запроса к модели не меняется, кэш модели цел (SC-003).\nconst findVideosDescription =\n \"Ищет видео в каталоге приложения: уроки, лекции, записи. Отдаёт до 8 подходящих с id, названием, описанием, \" +\n \"длительностью и главами. Вызывай, когда человек просит видео, урок или запись по теме, и перед show_video.\";\nconst showVideoDescription =\n \"Показывает человеку видео из каталога карточкой с плеером. Передай id из результата find_videos — другие id не подойдут. \" +\n \"startSeconds — с какой секунды начать, если нужна конкретная глава (возьми «секунда» из глав). \" +\n \"Ссылку на видео в ответ не вставляй: карточка уже на экране. Назови видео одной фразой.\";\n\nconst findVideosInput = z.object({\n query: z.string().min(1).max(200).describe(\"Слова для поиска: тема или понятие, о котором видео.\"),\n});\nconst showVideoInput = z.object({\n id: z.string().min(1).max(200).describe(\"id видео из результата find_videos.\"),\n startSeconds: z.number().int().min(0).optional().describe(\"С какой секунды начать. Не указывай, чтобы начать с начала.\"),\n});\n\nfunction forModel(video: VideoEntry) {\n const description = video.description?.trim();\n return {\n id: video.id,\n название: video.title,\n ...(description && { описание: description.length > MAX_DESCRIPTION ? `${description.slice(0, MAX_DESCRIPTION - 1)}…` : description }),\n ...(video.durationSeconds !== undefined && { длительность: timeLabel(video.durationSeconds) }),\n ...(video.chapters?.length && {\n главы: video.chapters.map((chapter) => ({ название: chapter.title, начало: timeLabel(chapter.startSeconds), секунда: chapter.startSeconds })),\n }),\n };\n}\n\nfunction shown(video: VideoEntry, startSeconds: number | undefined): ShownVideo {\n const address = parseVideoUrl(video.url)!;\n return {\n видео: {\n id: video.id,\n название: video.title,\n площадка: address.provider,\n код: address.code,\n ...(video.durationSeconds !== undefined && { длительность: video.durationSeconds }),\n ...(video.poster !== undefined && { обложка: video.poster }),\n ...(startSeconds && { начало: startSeconds }),\n },\n };\n}\n\nconst shortId = (id: unknown) => (typeof id === \"string\" ? id.slice(0, 200) : \"(без id)\");\n\n/** Инструменты видео поверх каталога приложения: модель ищет и показывает, сервер принимает только id из каталога. */\nexport function createVideoLibrary(options: CreateVideoLibraryOptions): VideoLibrary {\n const logError = options.logError ?? ((message: string) => console.error(message));\n const catalog = options.videos ? [...options.videos] : undefined;\n if (catalog) checkVideoCatalog(catalog);\n const byId = new Map(catalog?.map((video) => [video.id, video]));\n\n /** Негодная запись из базы приложения пропускается; в журнал — только id и причина. */\n const usable = (video: VideoEntry | null | undefined): video is VideoEntry => {\n if (video === undefined || video === null) return false;\n const reason = checkVideoEntry(video);\n if (reason) logError(`Видео «${shortId(video?.id)}» из каталога приложения пропущено: ${reason}.`);\n return !reason;\n };\n\n /** Сбой функции приложения — в журнал сервера; модели только нейтральное «недоступен», без текста исключения. */\n const unavailable = (what: string, reason: unknown): typeof CATALOG_UNAVAILABLE_MARK => {\n logError(`Каталог видео приложения: ${what} — ${reason instanceof Error ? reason.message : String(reason)}.`);\n return CATALOG_UNAVAILABLE_MARK;\n };\n\n async function find(query: string, context: VideoContext): Promise<VideoEntry[] | typeof CATALOG_UNAVAILABLE_MARK> {\n if (catalog) return searchVideos(catalog, query);\n let found: unknown;\n try {\n found = await options.source!.search(query, context);\n } catch (error) {\n return unavailable(\"search упал\", error);\n }\n if (!Array.isArray(found)) return unavailable(\"search вернул не список\", found === null ? \"null\" : typeof found);\n return (found as VideoEntry[]).filter(usable).slice(0, MAX_FOUND);\n }\n\n async function get(id: string, context: VideoContext): Promise<VideoEntry | undefined | typeof CATALOG_UNAVAILABLE_MARK> {\n if (catalog) return byId.get(id);\n let video: VideoEntry | null | undefined;\n try {\n video = await options.source!.get(id, context);\n } catch (error) {\n return unavailable(\"get упал\", error);\n }\n return usable(video) && video.id === id ? video : undefined;\n }\n\n const findVideos = (context: VideoContext) =>\n tool({\n description: findVideosDescription,\n inputSchema: findVideosInput,\n execute: async ({ query }) => {\n const result = await find(query, context);\n if (result === CATALOG_UNAVAILABLE_MARK) return { отказ: CATALOG_UNAVAILABLE };\n const found = result.map(forModel);\n return found.length > 0 ? { найдено: found } : { найдено: [], совет: NOTHING_FOUND };\n },\n });\n\n const showVideo = (context: VideoContext) =>\n tool({\n description: showVideoDescription,\n inputSchema: showVideoInput,\n execute: async ({ id, startSeconds }): Promise<ShownVideo> => {\n const video = await get(id, context);\n if (video === CATALOG_UNAVAILABLE_MARK) return { отказ: CATALOG_UNAVAILABLE };\n if (!video) return { отказ: NOT_IN_CATALOG };\n if (startSeconds !== undefined && video.durationSeconds !== undefined && startSeconds >= video.durationSeconds) {\n return { отказ: `Начало ${timeLabel(startSeconds)} за концом видео: оно длится ${timeLabel(video.durationSeconds)}.` };\n }\n return shown(video, startSeconds);\n },\n // Модели — коротко, что показано; карточке — весь результат.\n toModelOutput: ({ output }) => {\n if (output && \"видео\" in output) {\n const { название, начало } = output.видео;\n return { type: \"json\", value: { показано: название, ...(начало && { с: timeLabel(начало) }) } };\n }\n return { type: \"json\", value: { отказ: output?.отказ ?? NOT_IN_CATALOG } };\n },\n });\n\n return {\n tools(model): ToolSet {\n if (!model.ownerId || !model.chatId) return {};\n const context = { ownerId: model.ownerId, chatId: model.chatId };\n return { find_videos: findVideos(context), show_video: showVideo(context) };\n },\n };\n}\n","import { isPosterAddress, parseVideoUrl } from \"./video-address\";\n\n/** Запись каталога приложения: что приложение знает о видео. */\nexport interface VideoEntry {\n /** 1–200 знаков; модель передаёт его в `show_video`. */\n id: string;\n title: string;\n /** Ссылка на YouTube или Kinescope. */\n url: string;\n description?: string;\n /** Целое больше нуля. */\n durationSeconds?: number;\n /** `https://…` или путь от корня сайта `/…`. Нет — скин рисует заглушку, без запросов к площадке. */\n poster?: string;\n chapters?: Array<{ title: string; startSeconds: number }>;\n}\n\nconst MAX_ID_LENGTH = 200;\nexport const MAX_FOUND = 8;\n\nconst isText = (value: unknown): value is string => typeof value === \"string\" && value.trim() !== \"\";\n\n/** Что не так с записью; `undefined` — запись годная. Текст без содержимого записи, кроме id. */\nexport function checkVideoEntry(entry: VideoEntry): string | undefined {\n if (typeof entry?.id !== \"string\" || entry.id === \"\" || entry.id.length > MAX_ID_LENGTH) return \"id должен быть строкой от 1 до 200 знаков\";\n if (!isText(entry.title)) return \"пустое название\";\n if (typeof entry.url !== \"string\" || !parseVideoUrl(entry.url)) return \"ссылка не на YouTube и не на Kinescope\";\n if (entry.description !== undefined && typeof entry.description !== \"string\") return \"описание не строка\";\n const duration = entry.durationSeconds;\n if (duration !== undefined && (!Number.isInteger(duration) || duration <= 0)) return \"длительность должна быть целым числом секунд больше нуля\";\n if (entry.poster !== undefined && !isPosterAddress(entry.poster)) {\n return \"обложка должна быть адресом https:// или путём от корня сайта «/…»\";\n }\n if (entry.chapters !== undefined) {\n if (!Array.isArray(entry.chapters)) return \"главы должны быть списком\";\n for (const chapter of entry.chapters) {\n if (!isText(chapter?.title)) return \"глава без названия\";\n const start = chapter.startSeconds;\n if (!Number.isInteger(start) || start < 0) return \"начало главы должно быть целым числом секунд от нуля\";\n if (duration !== undefined && start >= duration) return \"глава начинается за концом видео\";\n }\n }\n return undefined;\n}\n\n/** Каталог-массив проверяется целиком при создании: ошибка разработчика видна сразу, с id записи. */\nexport function checkVideoCatalog(videos: readonly VideoEntry[]): void {\n const seen = new Set<string>();\n videos.forEach((video, index) => {\n const reason = checkVideoEntry(video);\n const name = typeof video?.id === \"string\" && video.id !== \"\" ? `«${video.id}»` : `№${index + 1}`;\n if (reason) throw new Error(`Каталог видео: запись ${name} — ${reason}.`);\n if (seen.has(video.id)) throw new Error(`Каталог видео: id ${name} повторяется.`);\n seen.add(video.id);\n });\n}\n\n// Встроенный поиск по словам: без регистра, «ё» как «е», по началам слов — так «промптов» находит «промпты».\nconst serviceWords = new Set(\n \"видео ролик ролика ролики роликов урок урока уроки уроку уроком уроков лекция лекции лекцию лекций про как что это для или на по из от\".split(\" \"),\n);\n\nconst words = (text: string) =>\n text\n .toLowerCase()\n .replaceAll(\"ё\", \"е\")\n .split(/[^\\p{L}\\p{N}]+/u)\n .filter((word) => word.length >= 2);\n\n/** Начало слова, по которому сравниваем: окончания падежей отбрасываются. */\nconst stem = (word: string) => (word.length > 5 ? word.slice(0, 5) : word.length >= 4 ? word.slice(0, -1) : word);\n\nconst weights = { title: 3, chapters: 2, description: 1 } as const;\n\nfunction score(video: VideoEntry, queryStems: string[]): number {\n const fields = {\n title: words(video.title),\n chapters: (video.chapters ?? []).flatMap((chapter) => words(chapter.title)),\n description: words(video.description ?? \"\"),\n };\n let total = 0;\n for (const queryStem of queryStems) {\n for (const field of [\"title\", \"chapters\", \"description\"] as const) {\n if (fields[field].some((word) => word.startsWith(queryStem))) total += weights[field];\n }\n }\n return total;\n}\n\n/** До восьми подходящих: название весит больше глав, главы — больше описания; при равенстве — порядок каталога. */\nexport function searchVideos(videos: readonly VideoEntry[], query: string): VideoEntry[] {\n const all = words(query);\n const meaningful = all.filter((word) => !serviceWords.has(word));\n const queryStems = [...new Set((meaningful.length > 0 ? meaningful : all).map(stem))];\n if (queryStems.length === 0) return [];\n return videos\n .map((video, index) => ({ video, index, score: score(video, queryStems) }))\n .filter((found) => found.score > 0)\n .sort((a, b) => b.score - a.score || a.index - b.index)\n .slice(0, MAX_FOUND)\n .map((found) => found.video);\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {
|
|
2
|
+
embedUrl,
|
|
3
|
+
isPosterAddress,
|
|
4
|
+
isVideoAddress,
|
|
5
|
+
timeLabel,
|
|
6
|
+
watchUrl
|
|
7
|
+
} from "../chunk-UROHGMNR.js";
|
|
8
|
+
|
|
9
|
+
// src/react/videos-client.ts
|
|
10
|
+
var TOOL_NAME = "show_video";
|
|
11
|
+
var wholeSeconds = (value, min) => typeof value === "number" && Number.isInteger(value) && value >= min ? value : void 0;
|
|
12
|
+
var safePoster = (value) => isPosterAddress(value) ? value : void 0;
|
|
13
|
+
var videosClient = {
|
|
14
|
+
toolName: TOOL_NAME,
|
|
15
|
+
describe(part) {
|
|
16
|
+
if (part.state === "input-streaming" || part.state === "input-available") return { state: "loading" };
|
|
17
|
+
if (part.state !== "output-available") return void 0;
|
|
18
|
+
const output = part.output;
|
|
19
|
+
const video = typeof output === "object" && output !== null ? output["\u0432\u0438\u0434\u0435\u043E"] : void 0;
|
|
20
|
+
if (typeof video !== "object" || video === null) return void 0;
|
|
21
|
+
const address = { provider: video["\u043F\u043B\u043E\u0449\u0430\u0434\u043A\u0430"], code: video["\u043A\u043E\u0434"] };
|
|
22
|
+
const title = video["\u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435"];
|
|
23
|
+
if (!isVideoAddress(address) || typeof title !== "string" || title.trim() === "") return void 0;
|
|
24
|
+
const start = wholeSeconds(video["\u043D\u0430\u0447\u0430\u043B\u043E"], 1);
|
|
25
|
+
const duration = wholeSeconds(video["\u0434\u043B\u0438\u0442\u0435\u043B\u044C\u043D\u043E\u0441\u0442\u044C"], 1);
|
|
26
|
+
const poster = safePoster(video["\u043E\u0431\u043B\u043E\u0436\u043A\u0430"]);
|
|
27
|
+
const view = {
|
|
28
|
+
id: part.toolCallId,
|
|
29
|
+
title,
|
|
30
|
+
provider: address.provider,
|
|
31
|
+
embedUrl: embedUrl(address, start),
|
|
32
|
+
pageUrl: watchUrl(address, start),
|
|
33
|
+
...poster && { poster },
|
|
34
|
+
...duration !== void 0 && { duration: timeLabel(duration) },
|
|
35
|
+
...start !== void 0 && { start: timeLabel(start) }
|
|
36
|
+
};
|
|
37
|
+
return { state: "ready", video: view };
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
export {
|
|
41
|
+
videosClient
|
|
42
|
+
};
|
|
43
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/react/videos-client.ts"],"sourcesContent":["import type { AgentVideoView, VideosClient } from \"@ykurilov/agent-chat-react\";\nimport { timeLabel } from \"../time-label\";\nimport { embedUrl, isPosterAddress, isVideoAddress, watchUrl } from \"../video-address\";\n\nconst TOOL_NAME = \"show_video\";\n\nconst wholeSeconds = (value: unknown, min: number): number | undefined =>\n typeof value === \"number\" && Number.isInteger(value) && value >= min ? value : undefined;\n\n/** Обложка — только https или путь от корня своего сайта. Иначе заглушка скина. */\nconst safePoster = (value: unknown): string | undefined => (isPosterAddress(value) ? value : undefined);\n\n/**\n * Видео агента на экране: вызов `show_video` → вид карточки. Пока идёт — `loading`; отказ, ошибка, «Стоп» и подделка\n * в базе — `undefined` (строка инструмента). Адреса плеера и страницы экран собирает сам из площадки и проверенного кода.\n */\nexport const videosClient: VideosClient = {\n toolName: TOOL_NAME,\n describe(part) {\n if (part.state === \"input-streaming\" || part.state === \"input-available\") return { state: \"loading\" };\n if (part.state !== \"output-available\") return undefined;\n\n const output = part.output as Record<string, unknown> | null;\n const video = typeof output === \"object\" && output !== null ? (output[\"видео\"] as Record<string, unknown> | undefined) : undefined;\n if (typeof video !== \"object\" || video === null) return undefined;\n const address = { provider: video[\"площадка\"], code: video[\"код\"] };\n const title = video[\"название\"];\n if (!isVideoAddress(address) || typeof title !== \"string\" || title.trim() === \"\") return undefined;\n\n const start = wholeSeconds(video[\"начало\"], 1);\n const duration = wholeSeconds(video[\"длительность\"], 1);\n const poster = safePoster(video[\"обложка\"]);\n const view: AgentVideoView = {\n id: part.toolCallId,\n title,\n provider: address.provider,\n embedUrl: embedUrl(address, start),\n pageUrl: watchUrl(address, start),\n ...(poster && { poster }),\n ...(duration !== undefined && { duration: timeLabel(duration) }),\n ...(start !== undefined && { start: timeLabel(start) }),\n };\n return { state: \"ready\", video: view };\n },\n};\n"],"mappings":";;;;;;;;;AAIA,IAAM,YAAY;AAElB,IAAM,eAAe,CAAC,OAAgB,QACpC,OAAO,UAAU,YAAY,OAAO,UAAU,KAAK,KAAK,SAAS,MAAM,QAAQ;AAGjF,IAAM,aAAa,CAAC,UAAwC,gBAAgB,KAAK,IAAI,QAAQ;AAMtF,IAAM,eAA6B;AAAA,EACxC,UAAU;AAAA,EACV,SAAS,MAAM;AACb,QAAI,KAAK,UAAU,qBAAqB,KAAK,UAAU,kBAAmB,QAAO,EAAE,OAAO,UAAU;AACpG,QAAI,KAAK,UAAU,mBAAoB,QAAO;AAE9C,UAAM,SAAS,KAAK;AACpB,UAAM,QAAQ,OAAO,WAAW,YAAY,WAAW,OAAQ,OAAO,gCAAO,IAA4C;AACzH,QAAI,OAAO,UAAU,YAAY,UAAU,KAAM,QAAO;AACxD,UAAM,UAAU,EAAE,UAAU,MAAM,kDAAU,GAAG,MAAM,MAAM,oBAAK,EAAE;AAClE,UAAM,QAAQ,MAAM,kDAAU;AAC9B,QAAI,CAAC,eAAe,OAAO,KAAK,OAAO,UAAU,YAAY,MAAM,KAAK,MAAM,GAAI,QAAO;AAEzF,UAAM,QAAQ,aAAa,MAAM,sCAAQ,GAAG,CAAC;AAC7C,UAAM,WAAW,aAAa,MAAM,0EAAc,GAAG,CAAC;AACtD,UAAM,SAAS,WAAW,MAAM,4CAAS,CAAC;AAC1C,UAAM,OAAuB;AAAA,MAC3B,IAAI,KAAK;AAAA,MACT;AAAA,MACA,UAAU,QAAQ;AAAA,MAClB,UAAU,SAAS,SAAS,KAAK;AAAA,MACjC,SAAS,SAAS,SAAS,KAAK;AAAA,MAChC,GAAI,UAAU,EAAE,OAAO;AAAA,MACvB,GAAI,aAAa,UAAa,EAAE,UAAU,UAAU,QAAQ,EAAE;AAAA,MAC9D,GAAI,UAAU,UAAa,EAAE,OAAO,UAAU,KAAK,EAAE;AAAA,IACvD;AACA,WAAO,EAAE,OAAO,SAAS,OAAO,KAAK;AAAA,EACvC;AACF;","names":[]}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ToolSet } from "ai";
|
|
2
|
+
import type { VideoProvider } from "./video-address.js";
|
|
3
|
+
import type { VideoEntry } from "./video-catalog.js";
|
|
4
|
+
/** Кто спрашивает: по нему функции приложения могут учитывать права человека. */
|
|
5
|
+
export interface VideoContext {
|
|
6
|
+
ownerId: string;
|
|
7
|
+
chatId: string;
|
|
8
|
+
}
|
|
9
|
+
/** Каталог функциями — для большой базы или своего поиска. Записи проверяются на каждом вызове. */
|
|
10
|
+
export interface VideoSource {
|
|
11
|
+
search(query: string, context: VideoContext): VideoEntry[] | Promise<VideoEntry[]>;
|
|
12
|
+
get(id: string, context: VideoContext): VideoEntry | undefined | Promise<VideoEntry | undefined>;
|
|
13
|
+
}
|
|
14
|
+
export type CreateVideoLibraryOptions = ({
|
|
15
|
+
videos: VideoEntry[];
|
|
16
|
+
source?: never;
|
|
17
|
+
} | {
|
|
18
|
+
source: VideoSource;
|
|
19
|
+
videos?: never;
|
|
20
|
+
}) & {
|
|
21
|
+
/** Куда писать о негодных записях из функций приложения. По умолчанию — `console.error`. */
|
|
22
|
+
logError?: (message: string) => void;
|
|
23
|
+
};
|
|
24
|
+
/** Выбранная модель хода: ядро передаёт в `agent.tools(model)` и того, чей ход, и чат. */
|
|
25
|
+
export interface VideoLibraryModel {
|
|
26
|
+
ownerId?: string;
|
|
27
|
+
chatId?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface VideoLibrary {
|
|
30
|
+
/** `{ find_videos, show_video }` для `agent.tools`; без `ownerId` и `chatId` — пустой набор. */
|
|
31
|
+
tools(model: VideoLibraryModel): ToolSet;
|
|
32
|
+
}
|
|
33
|
+
/** Результат `show_video`: хранится в сообщении и целиком нужен карточке — даже если видео потом убрали из каталога. */
|
|
34
|
+
export type ShownVideo = {
|
|
35
|
+
видео: {
|
|
36
|
+
id: string;
|
|
37
|
+
название: string;
|
|
38
|
+
площадка: VideoProvider;
|
|
39
|
+
код: string;
|
|
40
|
+
длительность?: number;
|
|
41
|
+
обложка?: string;
|
|
42
|
+
начало?: number;
|
|
43
|
+
};
|
|
44
|
+
} | {
|
|
45
|
+
отказ: string;
|
|
46
|
+
};
|
|
47
|
+
/** Инструменты видео поверх каталога приложения: модель ищет и показывает, сервер принимает только id из каталога. */
|
|
48
|
+
export declare function createVideoLibrary(options: CreateVideoLibraryOptions): VideoLibrary;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createVideoLibrary } from "./create-video-library.js";
|
|
2
|
+
export type { CreateVideoLibraryOptions, ShownVideo, VideoContext, VideoLibrary, VideoLibraryModel, VideoSource } from "./create-video-library.js";
|
|
3
|
+
export { searchVideos } from "./video-catalog.js";
|
|
4
|
+
export type { VideoEntry } from "./video-catalog.js";
|
|
5
|
+
export { embedUrl, isVideoAddress, parseVideoUrl, watchUrl } from "./video-address.js";
|
|
6
|
+
export type { VideoAddress, VideoProvider } from "./video-address.js";
|
|
7
|
+
export { timeLabel } from "./time-label.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { VideosClient } from "@ykurilov/agent-chat-react";
|
|
2
|
+
/**
|
|
3
|
+
* Видео агента на экране: вызов `show_video` → вид карточки. Пока идёт — `loading`; отказ, ошибка, «Стоп» и подделка
|
|
4
|
+
* в базе — `undefined` (строка инструмента). Адреса плеера и страницы экран собирает сам из площадки и проверенного кода.
|
|
5
|
+
*/
|
|
6
|
+
export declare const videosClient: VideosClient;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type VideoProvider = "youtube" | "kinescope";
|
|
2
|
+
export interface VideoAddress {
|
|
3
|
+
provider: VideoProvider;
|
|
4
|
+
code: string;
|
|
5
|
+
}
|
|
6
|
+
/** Площадка известна, код — только из допустимых знаков. */
|
|
7
|
+
export declare function isVideoAddress(value: unknown): value is VideoAddress;
|
|
8
|
+
/**
|
|
9
|
+
* Обложка — `https://…` или путь от корня своего сайта. Браузер читает `/\evil`, `/<таб>/evil` как `//evil` — чужой хост,
|
|
10
|
+
* поэтому путь разбирается как адрес и обязан остаться на своём хосте; `\`, пробелы, управляющие знаки и закодированные
|
|
11
|
+
* косые не допускаются вовсе.
|
|
12
|
+
*/
|
|
13
|
+
export declare function isPosterAddress(value: unknown): value is string;
|
|
14
|
+
/** Ссылка YouTube (watch, youtu.be, embed, shorts, live) или Kinescope (`/<код>`, `/embed/<код>`) → площадка и код. */
|
|
15
|
+
export declare function parseVideoUrl(url: string): VideoAddress | undefined;
|
|
16
|
+
/** Адрес встроенного плеера: YouTube — с youtube-nocookie.com; оба с автозапуском (грузится по нажатию) и стартом. */
|
|
17
|
+
export declare function embedUrl(address: VideoAddress, startSeconds?: number): string;
|
|
18
|
+
/** Адрес страницы видео на площадке — для ссылки, когда скин не умеет карточку. */
|
|
19
|
+
export declare function watchUrl(address: VideoAddress, startSeconds?: number): string;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Запись каталога приложения: что приложение знает о видео. */
|
|
2
|
+
export interface VideoEntry {
|
|
3
|
+
/** 1–200 знаков; модель передаёт его в `show_video`. */
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
/** Ссылка на YouTube или Kinescope. */
|
|
7
|
+
url: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
/** Целое больше нуля. */
|
|
10
|
+
durationSeconds?: number;
|
|
11
|
+
/** `https://…` или путь от корня сайта `/…`. Нет — скин рисует заглушку, без запросов к площадке. */
|
|
12
|
+
poster?: string;
|
|
13
|
+
chapters?: Array<{
|
|
14
|
+
title: string;
|
|
15
|
+
startSeconds: number;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
export declare const MAX_FOUND = 8;
|
|
19
|
+
/** Что не так с записью; `undefined` — запись годная. Текст без содержимого записи, кроме id. */
|
|
20
|
+
export declare function checkVideoEntry(entry: VideoEntry): string | undefined;
|
|
21
|
+
/** Каталог-массив проверяется целиком при создании: ошибка разработчика видна сразу, с id записи. */
|
|
22
|
+
export declare function checkVideoCatalog(videos: readonly VideoEntry[]): void;
|
|
23
|
+
/** До восьми подходящих: название весит больше глав, главы — больше описания; при равенстве — порядок каталога. */
|
|
24
|
+
export declare function searchVideos(videos: readonly VideoEntry[], query: string): VideoEntry[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ykurilov/agent-chat-videos",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.55.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md",
|
|
10
|
+
"CHANGELOG.md"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"@ykurilov/agent-chat-source": "./src/index.ts",
|
|
15
|
+
"types": "./dist/types/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./react": {
|
|
19
|
+
"@ykurilov/agent-chat-source": "./src/react/index.ts",
|
|
20
|
+
"types": "./dist/types/react/index.d.ts",
|
|
21
|
+
"import": "./dist/react/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"registry": "https://registry.npmjs.org",
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/ykurilov/agent-chat-sdk.git",
|
|
34
|
+
"directory": "packages/videos"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"ai": "7.0.122",
|
|
38
|
+
"zod": "^4.6.5"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"react": "^19.3.0",
|
|
42
|
+
"@ykurilov/agent-chat-react": "^0.55.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@testing-library/react": "^16.3.3",
|
|
46
|
+
"@types/react": "^19.3.0",
|
|
47
|
+
"jsdom": "^30.1.1",
|
|
48
|
+
"react": "^19.3.0",
|
|
49
|
+
"@ykurilov/agent-chat-react": "0.55.0"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"react": {
|
|
53
|
+
"optional": true
|
|
54
|
+
},
|
|
55
|
+
"@ykurilov/agent-chat-react": {
|
|
56
|
+
"optional": true
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "node ../../scripts/build-package.mjs",
|
|
61
|
+
"typecheck": "tsc -p tsconfig.json"
|
|
62
|
+
}
|
|
6
63
|
}
|