@router-ai/dsh-plugin-routerai 0.1.0 → 0.2.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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # История изменений
2
2
 
3
+ ## 0.2.0
4
+
5
+ - Агент рисует картинки: попросите его нарисовать иллюстрацию, логотип или схему — картинка появится в чате
6
+ и сохранится в папку `routerai-media` проекта. Модель выбирается в настройках, там же рисование можно выключить.
7
+ - Голосовой ввод: RouterAI работает как служба распознавания речи для плагина «Голосовой ввод» DeepSeek Harness.
8
+ Модель распознавания выбирается в настройках.
9
+ - Стоимость картинок входит в стоимость сессии — это точная сумма, которую сообщает RouterAI.
10
+ - Остаток в боковой панели обновляется быстрее после входа через команду `/routerai`.
11
+
3
12
  ## 0.1.0
4
13
 
5
14
  Первый выпуск.
package/README.md CHANGED
@@ -36,8 +36,23 @@ dsh plugin --profile desktop add @router-ai/dsh-plugin-routerai
36
36
  | Под полем ввода | стоимость текущей сессии, токены, доля входа из кэша |
37
37
  | Отказы из-за денег | понятное сообщение и ссылка вместо кода ошибки |
38
38
  | Служебная модель | названия сессий и сжатие контекста на недорогой модели |
39
+ | Картинки | агент рисует по просьбе: картинка в чате и файл в папке `routerai-media` проекта |
40
+ | Голосовой ввод | распознавание речи RouterAI для плагина «Голосовой ввод» |
39
41
 
40
- Стоимость сессии — расчёт по числу токенов и ценам каталога. Точные списания показывает кабинет RouterAI.
42
+ Стоимость сессии — расчёт по числу токенов и ценам каталога; стоимость картинок RouterAI сообщает точно.
43
+ Итоговые списания показывает кабинет RouterAI.
44
+
45
+ ### Картинки
46
+
47
+ Попросите агента: «нарисуй логотип для…», «сделай иллюстрацию к…». Модель для картинок выбирается в
48
+ **Настройки → RouterAI → Дополнительно**; там же рисование можно выключить, и тогда агент не будет тратить на него деньги.
49
+ Каждая картинка оплачивается отдельно, цена указана рядом с моделью.
50
+
51
+ ### Голосовой ввод
52
+
53
+ Голосовой ввод в DeepSeek Harness — отдельный экспериментальный плагин. Включите его
54
+ (пакет `@deepseek-ai/dsh-experimental-voice-input-bundle`) и выберите службу распознавания **RouterAI**.
55
+ Модель распознавания задаётся в тех же настройках RouterAI.
41
56
 
42
57
  ## Команда `/routerai`
43
58
 
package/docs/DETAIL.md CHANGED
@@ -16,4 +16,6 @@ others — behind a single key and bills in rubles. This plugin adds those model
16
16
  - Balance in the sidebar and the session cost under the composer.
17
17
  - Clear messages when the balance runs out or the key hits its spending limit.
18
18
  - Savings: session titles and context compaction run on an inexpensive utility model.
19
+ - Images: ask the agent to draw — the result appears in the chat and in the project's `routerai-media` folder.
20
+ - Voice input: RouterAI recognizes speech for the Voice input plugin.
19
21
  - The `/routerai` command for the same actions from the chat: `login`, `key`, `logout`, `status`, `models`, `find`.
package/docs/DETAIL.ru.md CHANGED
@@ -16,4 +16,6 @@ Qwen и многих других — за одним ключом и прини
16
16
  - Баланс в боковой панели и стоимость сессии под полем ввода.
17
17
  - Понятные сообщения, если закончились деньги или ключ упёрся в лимит.
18
18
  - Экономия: названия сессий и сжатие контекста делает недорогая служебная модель.
19
+ - Картинки: попросите агента нарисовать — результат появится в чате и в папке `routerai-media` проекта.
20
+ - Голосовой ввод: RouterAI распознаёт речь для плагина «Голосовой ввод».
19
21
  - Команда `/routerai` для тех же действий из чата: `login`, `key`, `logout`, `status`, `models`, `find`.
package/lib/client.js CHANGED
@@ -10,6 +10,8 @@ window.__ModuleLoader__.load({
10
10
  // src/links.ts
11
11
  const KEYS_PAGE = "https://routerai.ru/settings/keys";
12
12
  const BILLING_PAGE = "https://routerai.ru/settings/billing";
13
+ /** Имя инструмента рисования — по нему же браузерная часть подставляет свою карточку вызова. */
14
+ const DRAW_TOOL = "routerai_draw_image";
13
15
  // src/client/logo.ts
14
16
  const LOGO = "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20viewBox%3D%220%200%2024%2024%22%3E%3Cpath%20fill%3D%22%232d67f6%22%20d%3D%22M8.22%2021.06a4.09%204.09%200%200%200%207.55%200%204.1%204.1%200%200%200%205.34-5.32%204.08%204.08%200%200%200%200-7.53%204.07%204.07%200%200%200-5.34-5.33%204.09%204.09%200%200%200-7.55%200A4.1%204.1%200%200%200%202.9%208.21a4.08%204.08%200%200%200%200%207.53%204.07%204.07%200%200%200%205.33%205.32%22%2F%3E%3Cpath%20fill%3D%22%23fff%22%20d%3D%22M23.25%2011.97a3.7%203.7%200%200%200-2.28-3.42l-.35-.14.14-.35a3.7%203.7%200%200%200-4.84-4.83l-.35.14-.14-.34A3.7%203.7%200%200%200%2012.29.76L12%20.75c-1.54%200-2.87.94-3.43%202.28l-.14.34-.35-.14a3.72%203.72%200%200%200-4.85%204.83l.15.35-.35.14a3.7%203.7%200%200%200-2.28%203.42v.29a3.7%203.7%200%200%200%202.28%203.13l.35.14-.15.35a3.7%203.7%200%200%200%204.85%204.83l.35-.14.14.35a3.71%203.71%200%200%200%206.86%200l.14-.35.35.14a3.72%203.72%200%200%200%204.85-4.83l-.15-.35.35-.14a3.7%203.7%200%200%200%202.28-3.42M12%201.97c1.03%200%201.91.63%202.29%201.53l.61%201.48%201.49-.61a2.48%202.48%200%200%201%203.23%203.23l-.6%201.47%201.47.62c.9.37%201.53%201.26%201.53%202.28v.09a2.5%202.5%200%200%201-1.53%202.2l-1.48.6.61%201.49a2.47%202.47%200%200%201-3.23%203.23l-1.49-.61-.61%201.47a2.48%202.48%200%200%201-4.58%200l-.61-1.47-1.49.6a2.48%202.48%200%200%201-3.23-3.22l.6-1.48-1.47-.62a2.5%202.5%200%200%201-1.53-2.28v-.09c.04-.99.66-1.83%201.53-2.2l1.48-.6-.61-1.48A2.47%202.47%200%200%201%207.6%204.37l1.49.6.61-1.47A2.5%202.5%200%200%201%2012%201.98m0%2015.8q-.36%200-.66.13l-1.6.66.66%201.6a1.73%201.73%200%200%200%203.2%200l.67-1.6-1.61-.66q-.3-.13-.66-.13m-6.93-1.14a1.72%201.72%200%200%200%202.26%202.25l1.6-.65-.66-1.6a1.7%201.7%200%200%200-.93-.93l-1.61-.67zm11.6-.93a1.7%201.7%200%200%200-.94.93l-.66%201.6%201.6.65a1.74%201.74%200%200%200%202.26-2.25l-.66-1.6zM6.18%2011.97q0-.36-.13-.66l-.66-1.6-1.6.67c-.61.25-1.04.84-1.07%201.52v.08c0%20.7.44%201.32%201.07%201.58l1.6.67.66-1.6q.13-.3.13-.66m8.89%200q0-.71.21-1.36a4.5%204.5%200%200%201-1.93-1.92%204.5%204.5%200%200%201-2.72%200A4.4%204.4%200%200%201%208.7%2010.6a4.4%204.4%200%200%201%200%202.72%204.5%204.5%200%200%201%201.93%201.92%204.5%204.5%200%200%201%202.72%200%204.4%204.4%200%200%201%201.93-1.92%204%204%200%200%201-.21-1.36M7.33%205.06A1.74%201.74%200%200%200%205.07%207.3l.66%201.6%201.6-.66a1.7%201.7%200%200%200%20.94-.93l.66-1.6zm11.22.37c-.5-.5-1.25-.63-1.88-.37l-1.6.66.66%201.6a1.7%201.7%200%200%200%20.93.93l1.61.66.66-1.6c.26-.63.13-1.37-.38-1.88M12%202.73c-.72%200-1.34.43-1.6%201.06l-.67%201.6%201.61.66a1.7%201.7%200%200%200%201.32%200l1.6-.67-.66-1.6A1.7%201.7%200%200%200%2012%202.74m-5.06%209.24q0%20.5-.19.94l-.61%201.48%201.48.62a2.5%202.5%200%200%201%201.34%201.33l.61%201.48%201.49-.61a2.5%202.5%200%200%201%201.88%200l1.49.6.61-1.47A2.5%202.5%200%200%201%2016.38%2015l1.48-.62-.61-1.48a2.5%202.5%200%200%201%200-1.88l.6-1.48-1.47-.61a2.5%202.5%200%200%201-1.34-1.34l-.61-1.47-1.49.6a2.5%202.5%200%200%201-1.88%200l-1.49-.6-.61%201.47a2.5%202.5%200%200%201-1.34%201.34l-1.48.61.61%201.48q.19.43.19.94m8.89%200q0%20.76.27%201.41l.15.35-.35.14a3.7%203.7%200%200%200-2%202l-.14.34-.35-.14a3.7%203.7%200%200%200-2.82%200l-.35.14-.14-.34a3.7%203.7%200%200%200-2-2l-.35-.14.15-.35a3.7%203.7%200%200%200%200-2.82l-.15-.34.35-.15a3.7%203.7%200%200%200%202-2l.14-.34.35.15a3.7%203.7%200%200%200%202.82%200l.35-.15.14.35a3.7%203.7%200%200%200%202%202l.35.14-.15.34q-.26.65-.27%201.41m1.98%200q0%20.36.13.66l.66%201.6%201.6-.67a1.7%201.7%200%200%200%201.07-1.52v-.2a1.7%201.7%200%200%200-1.07-1.46l-1.6-.67-.66%201.6q-.12.3-.13.66m6.19%200c0%201.73-.98%203.22-2.42%203.96a4.44%204.44%200%200%201-5.61%205.6%204.46%204.46%200%200%201-7.94%200%204.47%204.47%200%200%201-5.6-5.6A4.5%204.5%200%200%201%200%2012.14v-.17c0-1.72.98-3.22%202.42-3.96a4.44%204.44%200%200%201%205.61-5.6A4.5%204.5%200%200%201%2012%200h.17a4.5%204.5%200%200%201%203.8%202.41%204.47%204.47%200%200%201%205.6%205.6A4.5%204.5%200%200%201%2024%2011.97%22%2F%3E%3Cpath%20fill%3D%22%23fff%22%20d%3D%22M10.44%208.22a4%204%200%200%200%203.12%200%204%204%200%200%200%202.2%202.2%204%204%200%200%200%200%203.1%204%204%200%200%200-2.2%202.2%204%204%200%200%200-3.12%200%204%204%200%200%200-2.2-2.2%204%204%200%200%200%200-3.1%204%204%200%200%200%202.2-2.2%22%2F%3E%3C%2Fsvg%3E";
15
17
  // src/client/parts.tsx
@@ -59,6 +61,7 @@ window.__ModuleLoader__.load({
59
61
  return payload;
60
62
  }
61
63
  const IDLE_REFRESH_MS = 45e3;
64
+ const DISCONNECTED_REFRESH_MS = 8e3;
62
65
  const WAITING_REFRESH_MS = 1200;
63
66
  /** Состояние плагина в этой вкладке. Опрашивает сервер редко, а пока идёт вход — часто. */
64
67
  var Remote = class {
@@ -100,9 +103,10 @@ window.__ModuleLoader__.load({
100
103
  }
101
104
  if (this.stopped) return;
102
105
  const waiting = this.view.overview?.signIn.stage === "waiting";
106
+ const pause = waiting ? WAITING_REFRESH_MS : this.view.overview?.connected === false ? DISCONNECTED_REFRESH_MS : IDLE_REFRESH_MS;
103
107
  this.timer = setTimeout(() => {
104
108
  this.pull(waiting);
105
- }, waiting ? WAITING_REFRESH_MS : IDLE_REFRESH_MS);
109
+ }, pause);
106
110
  }
107
111
  publish(view) {
108
112
  this.view = view;
@@ -202,6 +206,103 @@ window.__ModuleLoader__.load({
202
206
  if (tokens >= 1e6) return `${Math.round(tokens / 1e5) / 10}M`;
203
207
  return `${Math.round(tokens / 1e3)}K`;
204
208
  }
209
+ // src/client/DrawCard.tsx
210
+ /** Описание картинки из аргументов вызова; пока модель их дописывает, JSON может быть неполным. */
211
+ function promptOf(block) {
212
+ for (const raw of [block.argsRaw, block.call?.argsRaw]) {
213
+ if (raw === void 0) continue;
214
+ try {
215
+ const prompt = JSON.parse(raw).prompt;
216
+ if (typeof prompt === "string") return prompt;
217
+ } catch {
218
+ const partial = /"prompt"\s*:\s*"((?:[^"\\]|\\.)*)/.exec(raw)?.[1];
219
+ if (partial !== void 0) return partial.replace(/\\n/g, " ").replace(/\\(.)/g, "$1");
220
+ }
221
+ }
222
+ return "";
223
+ }
224
+ function Picture({ attachment, loadImage }) {
225
+ const [url, setUrl] = (0, react.useState)(null);
226
+ (0, react.useEffect)(() => {
227
+ let current = true;
228
+ loadImage(attachment).then((loaded) => {
229
+ if (current) setUrl(loaded);
230
+ }, () => {});
231
+ return () => {
232
+ current = false;
233
+ };
234
+ }, [attachment, loadImage]);
235
+ return url === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", {}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
236
+ src: url,
237
+ alt: attachment.name ?? "",
238
+ width: attachment.width,
239
+ height: attachment.height,
240
+ style: { height: "auto" }
241
+ });
242
+ }
243
+ function DrawCard({ t, phase, block, loadImage, openFile }) {
244
+ const prompt = promptOf(block);
245
+ if (phase !== "result") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
246
+ className: "rai-drawn",
247
+ "data-routerai": "draw-card",
248
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
249
+ className: "rai-line",
250
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "rai-busy" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", { children: t("drawing") })]
251
+ }), prompt === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
252
+ className: "rai-note",
253
+ children: prompt
254
+ })]
255
+ });
256
+ if (block.isError === true) {
257
+ const reason = block.content?.find((item) => item.type === "text")?.text ?? block.error?.reason ?? "";
258
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
259
+ className: "rai-drawn",
260
+ "data-routerai": "draw-card",
261
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", { children: t("drawFailed") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
262
+ className: "rai-alert",
263
+ children: reason
264
+ })]
265
+ });
266
+ }
267
+ const meta = block.meta ?? {};
268
+ const shown = (block.content ?? []).flatMap((item) => item.type === "image" && item.attachment !== void 0 ? [item.attachment] : []);
269
+ const facts = [meta.model, typeof meta.cost === "number" ? t("drawnCost", { cost: rub(meta.cost) }) : void 0].filter(Boolean).join(" · ");
270
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
271
+ className: "rai-drawn",
272
+ "data-routerai": "draw-card",
273
+ children: [
274
+ shown.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
275
+ className: "rai-gallery",
276
+ children: shown.map((attachment) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Picture, {
277
+ attachment,
278
+ loadImage
279
+ }, attachment.attachmentId))
280
+ }),
281
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
282
+ className: "rai-line",
283
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", { children: t("drawn") }), facts === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
284
+ className: "rai-faint",
285
+ children: facts
286
+ })]
287
+ }),
288
+ prompt === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
289
+ className: "rai-note",
290
+ children: prompt
291
+ }),
292
+ (meta.files ?? []).length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
293
+ className: "rai-line",
294
+ children: (meta.files ?? []).map((path) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
295
+ variant: "outline",
296
+ size: "sm",
297
+ onClick: () => {
298
+ openFile(path);
299
+ },
300
+ children: t("openFile", { name: path.split(/[\\/]/).pop() ?? path })
301
+ }, path))
302
+ })
303
+ ]
304
+ });
305
+ }
205
306
  // src/client/SessionCost.tsx
206
307
  const REFRESH_MS = 4e3;
207
308
  const count = (value) => value.toLocaleString("ru-RU");
@@ -568,13 +669,14 @@ window.__ModuleLoader__.load({
568
669
  (0, react.useEffect)(() => {
569
670
  setDraft(saved);
570
671
  }, [savedKey]);
571
- const bySlug = (0, react.useMemo)(() => new Map((catalog ?? []).map((entry) => [entry.slug, entry])), [catalog]);
572
- const makers = (0, react.useMemo)(() => [...new Set((catalog ?? []).map((entry) => entry.maker))].sort((a, b) => a.localeCompare(b)), [catalog]);
672
+ const chat = (0, react.useMemo)(() => (catalog ?? []).filter((entry) => entry.makes.includes("text")), [catalog]);
673
+ const bySlug = (0, react.useMemo)(() => new Map(chat.map((entry) => [entry.slug, entry])), [chat]);
674
+ const makers = (0, react.useMemo)(() => [...new Set(chat.map((entry) => entry.maker))].sort((a, b) => a.localeCompare(b)), [chat]);
573
675
  const matches = (0, react.useMemo)(() => {
574
676
  const needle = search.trim().toLowerCase();
575
- return (catalog ?? []).filter((entry) => (maker === "" || entry.maker === maker) && (!toolsOnly || entry.tools) && (needle === "" || `${entry.slug} ${entry.maker} ${entry.title}`.toLowerCase().includes(needle)));
677
+ return chat.filter((entry) => (maker === "" || entry.maker === maker) && (!toolsOnly || entry.tools) && (needle === "" || `${entry.slug} ${entry.maker} ${entry.title}`.toLowerCase().includes(needle)));
576
678
  }, [
577
- catalog,
679
+ chat,
578
680
  search,
579
681
  maker,
580
682
  toolsOnly
@@ -816,8 +918,11 @@ window.__ModuleLoader__.load({
816
918
  function Extras({ t, remote, overview, catalog }) {
817
919
  const [threshold, setThreshold] = (0, react.useState)(String(overview.lowBalance));
818
920
  const action = useAction();
819
- const cheapestFirst = (0, react.useMemo)(() => (catalog ?? []).filter((entry) => entry.tools && entry.price !== void 0 && !entry.alias).sort((a, b) => (a.price?.input ?? 0) - (b.price?.input ?? 0)), [catalog]);
921
+ const cheapestFirst = (0, react.useMemo)(() => (catalog ?? []).filter((entry) => entry.makes.includes("text") && entry.tools && entry.price !== void 0 && !entry.alias).sort((a, b) => (a.price?.input ?? 0) - (b.price?.input ?? 0)), [catalog]);
820
922
  const current = overview.utilityModel;
923
+ const painters = (0, react.useMemo)(() => (catalog ?? []).filter((entry) => entry.makes.includes("image")).sort((a, b) => (a.perImage ?? 1e400) - (b.perImage ?? 1e400) || a.title.localeCompare(b.title)), [catalog]);
924
+ const listeners = (0, react.useMemo)(() => (catalog ?? []).filter((entry) => entry.makes.includes("transcription")).sort((a, b) => (a.perSecond ?? 1e400) - (b.perSecond ?? 1e400) || a.title.localeCompare(b.title)), [catalog]);
925
+ const known = (list, slug) => slug === null || list.some((entry) => entry.slug === slug);
821
926
  const commitThreshold = () => {
822
927
  const value = Number(threshold.replace(",", "."));
823
928
  if (Number.isFinite(value) && value >= 0) action.run(() => remote.savePreferences({ lowBalance: value }));
@@ -866,6 +971,79 @@ window.__ModuleLoader__.load({
866
971
  })
867
972
  ]
868
973
  }),
974
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
975
+ style: {
976
+ display: "grid",
977
+ gap: 6
978
+ },
979
+ children: [
980
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("imageLabel") }),
981
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
982
+ className: "rai-choice",
983
+ "data-routerai": "image-model",
984
+ value: overview.imageModel ?? "",
985
+ onChange: (event) => {
986
+ action.run(() => remote.savePreferences({ imageModel: event.target.value === "" ? null : event.target.value }));
987
+ },
988
+ children: [
989
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
990
+ value: "",
991
+ children: t("imageOff")
992
+ }),
993
+ known(painters, overview.imageModel) ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
994
+ value: overview.imageModel ?? "",
995
+ children: overview.imageModel
996
+ }),
997
+ painters.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
998
+ value: entry.slug,
999
+ children: [
1000
+ entry.maker,
1001
+ " ",
1002
+ entry.title,
1003
+ entry.perImage === void 0 ? "" : ` · ${t("perImage", { price: rub(entry.perImage) })}`
1004
+ ]
1005
+ }, entry.slug))
1006
+ ]
1007
+ }),
1008
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1009
+ className: "rai-note",
1010
+ children: t("imageHint")
1011
+ })
1012
+ ]
1013
+ }),
1014
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1015
+ style: {
1016
+ display: "grid",
1017
+ gap: 6
1018
+ },
1019
+ children: [
1020
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("speechLabel") }),
1021
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
1022
+ className: "rai-choice",
1023
+ "data-routerai": "speech-model",
1024
+ value: overview.speechModel,
1025
+ onChange: (event) => {
1026
+ action.run(() => remote.savePreferences({ speechModel: event.target.value }));
1027
+ },
1028
+ children: [known(listeners, overview.speechModel) ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1029
+ value: overview.speechModel,
1030
+ children: overview.speechModel
1031
+ }), listeners.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
1032
+ value: entry.slug,
1033
+ children: [
1034
+ entry.maker,
1035
+ " ",
1036
+ entry.title,
1037
+ entry.perSecond === void 0 ? "" : ` · ${t("perMinute", { price: rub(entry.perSecond * 60) })}`
1038
+ ]
1039
+ }, entry.slug))]
1040
+ }),
1041
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1042
+ className: "rai-note",
1043
+ children: t("speechHint")
1044
+ })
1045
+ ]
1046
+ }),
869
1047
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
870
1048
  className: "rai-line",
871
1049
  children: [
@@ -1103,6 +1281,12 @@ label.rai-row{cursor:pointer}
1103
1281
  .rai-sheet li>span{flex:1;min-width:0}
1104
1282
  .rai-banner{display:flex;align-items:center;gap:12px;max-width:540px;padding:12px 14px;border-radius:var(--dsw-radius-md);font:var(--dsw-font-s-14);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l3);box-shadow:0 8px 24px rgba(0,0,0,.14)}
1105
1283
  .rai-banner>div{flex:1;min-width:0;display:grid;gap:2px}
1284
+ .rai-drawn{display:grid;gap:8px;padding:10px 12px;border-radius:var(--dsw-radius-md);font:var(--dsw-font-s-14);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l3)}
1285
+ .rai-gallery{display:flex;flex-wrap:wrap;gap:8px}
1286
+ .rai-gallery img,.rai-gallery i{display:block;max-width:min(320px,100%);max-height:320px;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-2)}
1287
+ .rai-gallery i{width:200px;height:200px}
1288
+ .rai-busy{width:14px;height:14px;flex:none;border-radius:50%;border:2px solid var(--dsw-alias-border-l3);border-top-color:${ACCENT};animation:rai-turn .8s linear infinite}
1289
+ @keyframes rai-turn{to{transform:rotate(1turn)}}
1106
1290
  `;
1107
1291
  /** Подключить стили. Атрибуты data-plugin* — соглашение harness: по ним он снимает стили выгруженного плагина. */
1108
1292
  function mountStyles(owner) {
@@ -1172,11 +1356,23 @@ label.rai-row{cursor:pointer}
1172
1356
  utilityHint: "На ней придумываются названия сессий и сжимается длинный контекст. Дешёвая модель экономит деньги и не влияет на ответы.",
1173
1357
  utilityOff: "Не использовать — всё делает модель разговора",
1174
1358
  lowBalanceLabel: "Предупреждать о балансе ниже",
1359
+ imageLabel: "Модель для картинок",
1360
+ imageHint: "Попросите агента нарисовать что-нибудь — картинка появится в чате и сохранится в папку routerai-media проекта. Каждая картинка оплачивается отдельно.",
1361
+ imageOff: "Не рисовать картинки",
1362
+ perImage: "{price} за картинку",
1363
+ speechLabel: "Модель распознавания речи",
1364
+ speechHint: "Работает с голосовым вводом DeepSeek Harness: включите плагин «Голосовой ввод» и выберите в нём службу RouterAI.",
1365
+ perMinute: "{price} за минуту",
1175
1366
  sidebarConnect: "Войти",
1176
1367
  sidebarTitle: "RouterAI",
1177
1368
  sidebarLow: "Баланс подходит к концу.",
1178
1369
  sidebarHint: "Модели и ключ настраиваются в Настройках → RouterAI.",
1179
1370
  close: "Закрыть",
1371
+ drawing: "Рисую картинку…",
1372
+ drawn: "Картинка",
1373
+ drawFailed: "Нарисовать не получилось",
1374
+ drawnCost: "стоимость {cost}",
1375
+ openFile: "Открыть {name}",
1180
1376
  costTitle: "Стоимость сессии",
1181
1377
  costTotal: "Всего",
1182
1378
  costLast: "Последний запрос",
@@ -1251,11 +1447,23 @@ label.rai-row{cursor:pointer}
1251
1447
  utilityHint: "It names sessions and compacts long context. A cheap model saves money and does not affect answers.",
1252
1448
  utilityOff: "Off — the conversation model does everything",
1253
1449
  lowBalanceLabel: "Warn when the balance is below",
1450
+ imageLabel: "Image model",
1451
+ imageHint: "Ask the agent to draw something: the image appears in the chat and is saved to the routerai-media folder of the project. Each image is billed separately.",
1452
+ imageOff: "Do not draw images",
1453
+ perImage: "{price} per image",
1454
+ speechLabel: "Speech recognition model",
1455
+ speechHint: "Used by DeepSeek Harness voice input: enable the Voice input plugin and choose the RouterAI service there.",
1456
+ perMinute: "{price} per minute",
1254
1457
  sidebarConnect: "Sign in",
1255
1458
  sidebarTitle: "RouterAI",
1256
1459
  sidebarLow: "The balance is running out.",
1257
1460
  sidebarHint: "Models and the key are configured in Settings → RouterAI.",
1258
1461
  close: "Close",
1462
+ drawing: "Drawing the image…",
1463
+ drawn: "Image",
1464
+ drawFailed: "Could not draw the image",
1465
+ drawnCost: "cost {cost}",
1466
+ openFile: "Open {name}",
1259
1467
  costTitle: "Session cost",
1260
1468
  costTotal: "Total",
1261
1469
  costLast: "Last request",
@@ -1338,6 +1546,11 @@ label.rai-row{cursor:pointer}
1338
1546
  component: MoneyNotice
1339
1547
  }
1340
1548
  ];
1549
+ placements.push({
1550
+ slot: "tool.call.toolview",
1551
+ options: { key: DRAW_TOOL },
1552
+ component: DrawCard
1553
+ });
1341
1554
  for (const { slot, options, component } of placements) slots.inject(slot, () => slots.register({
1342
1555
  name: slot,
1343
1556
  locale: NAMESPACE,
package/lib/index.js CHANGED
@@ -9,12 +9,17 @@ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
9
9
  import { spawn } from "node:child_process";
10
10
  import { createHash, randomBytes } from "node:crypto";
11
11
  import { createServer } from "node:http";
12
+ import { defineTool } from "@deepseek-ai/dsh-tools";
12
13
  import { InMemoryCredentialStore, createProvider } from "@earendil-works/pi-ai";
13
14
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
14
15
  // src/links.ts
15
16
  const API_BASE = "https://routerai.ru/api/v1";
16
17
  const KEYS_PAGE = "https://routerai.ru/settings/keys";
17
18
  const BILLING_PAGE = "https://routerai.ru/settings/billing";
19
+ /** Имя инструмента рисования — по нему же браузерная часть подставляет свою карточку вызова. */
20
+ const DRAW_TOOL = "routerai_draw_image";
21
+ /** Папка в рабочем каталоге проекта, куда сохраняются нарисованные картинки. */
22
+ const MEDIA_FOLDER = "routerai-media";
18
23
  /** Идентификатор маршрута в harness: так провайдер называется в пикере и конфигурации. */
19
24
  const ROUTE = "routerai";
20
25
  // src/failures.ts
@@ -319,6 +324,24 @@ var CostBook = class {
319
324
  };
320
325
  this.close(sessionId, entry);
321
326
  }
327
+ /** Учесть готовую сумму — например, картинку: RouterAI сам сообщает её стоимость. */
328
+ addCharge(sessionId, model, rub) {
329
+ const entry = this.open(sessionId);
330
+ entry.total += rub;
331
+ entry.last = {
332
+ model,
333
+ rub
334
+ };
335
+ const share = entry.perModel[model] ?? {
336
+ calls: 0,
337
+ rub: 0
338
+ };
339
+ entry.perModel[model] = {
340
+ calls: share.calls + 1,
341
+ rub: share.rub + rub
342
+ };
343
+ this.close(sessionId, entry);
344
+ }
322
345
  open(sessionId) {
323
346
  const existing = this.sessions.get(sessionId);
324
347
  return existing === void 0 ? blank() : structuredClone(existing);
@@ -521,6 +544,8 @@ var Bridge = class {
521
544
  },
522
545
  utilityModel: preferences.current.utilityModel,
523
546
  lowBalance: preferences.current.lowBalance,
547
+ imageModel: preferences.current.imageModel,
548
+ speechModel: preferences.current.speechModel,
524
549
  problem: this.problem !== null && Date.now() - this.problem.at < PROBLEM_SHOWN_MS ? this.problem : null
525
550
  };
526
551
  }
@@ -631,6 +656,12 @@ var Bridge = class {
631
656
  }
632
657
  };
633
658
  // src/catalog.ts
659
+ const OUTPUTS = [
660
+ "text",
661
+ "image",
662
+ "transcription"
663
+ ];
664
+ const isChatModel = (card) => card.makes.includes("text");
634
665
  const SLUG = /^~?[a-z0-9][\w.-]*\/[a-z0-9][\w.:-]*$/i;
635
666
  function isModelSlug(value) {
636
667
  return typeof value === "string" && value.length <= 160 && SLUG.test(value);
@@ -666,6 +697,12 @@ function tokenPrice(pricing, units) {
666
697
  if (cacheWrite !== void 0) price.cacheWrite = cacheWrite;
667
698
  return price;
668
699
  }
700
+ /** Цена за картинку из `image_pricing`; модели с оплатой по токенам или мегапикселям так не оценить. */
701
+ function firstImagePrice(pricing) {
702
+ if (!Array.isArray(pricing)) return void 0;
703
+ const known = pricing.map(bag).filter((entry) => entry["unit"] === "image").map((entry) => amount(entry["price"])).filter((value) => value !== void 0);
704
+ return known.length === 0 ? void 0 : Math.min(...known);
705
+ }
669
706
  /** «Anthropic: Claude Sonnet 5.5» → производитель и название по отдельности. */
670
707
  function splitName(name, slug) {
671
708
  const colon = name.indexOf(": ");
@@ -679,13 +716,14 @@ function splitName(name, slug) {
679
716
  title: name.trim() || slug
680
717
  };
681
718
  }
682
- /** Одна запись ответа; undefined для повреждённых записей и моделей без текстового вывода. */
719
+ /** Одна запись ответа; undefined для повреждённых записей и моделей, с которыми плагин не работает (эмбеддинги, видео, озвучка). */
683
720
  function readModelCard(entry) {
684
721
  const raw = bag(entry);
685
722
  if (!isModelSlug(raw["id"])) return void 0;
686
723
  const architecture = bag(raw["architecture"]);
687
- const produces = strings(architecture["output_modalities"]);
688
- if (produces.length > 0 && !produces.includes("text")) return void 0;
724
+ const declared = strings(architecture["output_modalities"]);
725
+ const makes = declared.length === 0 ? ["text"] : OUTPUTS.filter((output) => declared.includes(output));
726
+ if (makes.length === 0) return void 0;
689
727
  const parameters = strings(raw["supported_parameters"]);
690
728
  const slug = raw["id"];
691
729
  const card = {
@@ -694,12 +732,19 @@ function readModelCard(entry) {
694
732
  alias: slug.startsWith("~"),
695
733
  tools: parameters.includes("tools"),
696
734
  reasoning: parameters.includes("reasoning") || parameters.includes("reasoning_effort"),
697
- vision: strings(architecture["input_modalities"]).includes("image")
735
+ vision: strings(architecture["input_modalities"]).includes("image"),
736
+ makes
698
737
  };
699
738
  const window = amount(raw["context_length"]);
700
739
  if (window !== void 0 && window > 0) card.window = window;
701
740
  const price = tokenPrice(bag(raw["pricing"]), bag(raw["pricing_units"]));
702
741
  if (price !== void 0) card.price = price;
742
+ const perImage = firstImagePrice(raw["image_pricing"]);
743
+ if (perImage !== void 0) card.perImage = perImage;
744
+ if (bag(raw["pricing_units"])["seconds"] === "second") {
745
+ const perSecond = amount(bag(raw["pricing"])["seconds"]);
746
+ if (perSecond !== void 0) card.perSecond = perSecond;
747
+ }
703
748
  return card;
704
749
  }
705
750
  /** Ответ `GET /models` целиком. */
@@ -726,11 +771,11 @@ async function downloadModelList(apiBase, signal) {
726
771
  }
727
772
  function acceptSnapshot(raw) {
728
773
  const value = bag(raw);
729
- if (value["format"] !== 1 || typeof value["source"] !== "string" || typeof value["savedAt"] !== "number") return void 0;
774
+ if (value["format"] !== 2 || typeof value["source"] !== "string" || typeof value["savedAt"] !== "number") return void 0;
730
775
  if (!Array.isArray(value["cards"])) return void 0;
731
- const cards = value["cards"].filter((card) => isModelSlug(bag(card)["slug"]));
776
+ const cards = value["cards"].filter((card) => isModelSlug(bag(card)["slug"]) && Array.isArray(bag(card)["makes"]));
732
777
  return {
733
- format: 1,
778
+ format: 2,
734
779
  source: value["source"],
735
780
  savedAt: value["savedAt"],
736
781
  cards
@@ -793,7 +838,7 @@ var ModelCatalog = class {
793
838
  this.lastError = void 0;
794
839
  this.adopt(cards, this.clock());
795
840
  await this.disk?.write({
796
- format: 1,
841
+ format: 2,
797
842
  source: this.options.apiBase,
798
843
  savedAt: this.stamp,
799
844
  cards
@@ -808,6 +853,99 @@ var ModelCatalog = class {
808
853
  for (const listener of this.listeners) listener();
809
854
  }
810
855
  };
856
+ // src/media.ts
857
+ var MediaError = class extends Error {
858
+ status;
859
+ constructor(message, status) {
860
+ super(message);
861
+ this.status = status;
862
+ }
863
+ };
864
+ async function post(apiBase, path, apiKey, body, signal, timeoutMs) {
865
+ let response;
866
+ try {
867
+ response = await fetch(`${apiBase}${path}`, {
868
+ method: "POST",
869
+ headers: {
870
+ authorization: `Bearer ${apiKey}`,
871
+ "content-type": "application/json",
872
+ accept: "application/json",
873
+ "X-Title": "DeepSeek Harness"
874
+ },
875
+ body: JSON.stringify(body),
876
+ signal: AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
877
+ });
878
+ } catch (error) {
879
+ if (signal.aborted) throw error;
880
+ throw new MediaError(`RouterAI не ответил: ${error instanceof Error ? error.message : String(error)}`);
881
+ }
882
+ const payload = await response.json().catch(() => null);
883
+ if (!response.ok || payload === null) {
884
+ const problem = payload?.["error"];
885
+ const detail = typeof problem === "string" ? problem : problem?.message;
886
+ throw new MediaError(typeof detail === "string" && detail !== "" ? detail : `RouterAI ответил HTTP ${response.status}`, response.status);
887
+ }
888
+ return payload;
889
+ }
890
+ function cost(payload) {
891
+ const value = payload["usage"]?.cost;
892
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
893
+ }
894
+ /** Тип картинки по первым байтам: RouterAI указывает его не для всех моделей. */
895
+ function sniffPictureType(bytes) {
896
+ const starts = (...signature) => signature.every((byte, index) => bytes[index] === byte);
897
+ if (starts(137, 80, 78, 71)) return "image/png";
898
+ if (starts(255, 216, 255)) return "image/jpeg";
899
+ if (starts(71, 73, 70, 56)) return "image/gif";
900
+ if (starts(82, 73, 70, 70) && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80) return "image/webp";
901
+ }
902
+ async function drawPictures(apiBase, apiKey, request, signal) {
903
+ const payload = await post(apiBase, "/images", apiKey, {
904
+ model: request.model,
905
+ prompt: request.prompt,
906
+ ...request.aspectRatio === void 0 ? {} : { aspect_ratio: request.aspectRatio },
907
+ ...request.count === void 0 ? {} : { n: request.count }
908
+ }, signal, 24e4);
909
+ const pictures = [];
910
+ for (const item of Array.isArray(payload["data"]) ? payload["data"] : []) {
911
+ if (typeof item?.["b64_json"] !== "string") continue;
912
+ const bytes = new Uint8Array(Buffer.from(item["b64_json"], "base64"));
913
+ const type = sniffPictureType(bytes);
914
+ if (type !== void 0) pictures.push({
915
+ bytes,
916
+ type
917
+ });
918
+ }
919
+ if (pictures.length === 0) throw new MediaError("RouterAI не вернул ни одной картинки в поддерживаемом формате (PNG, JPEG, WebP, GIF)");
920
+ const price = cost(payload);
921
+ return price === void 0 ? { pictures } : {
922
+ pictures,
923
+ cost: price
924
+ };
925
+ }
926
+ /** Длительность записи по заголовку WAV; 0, если заголовок не разобрать. */
927
+ function wavSeconds(wav) {
928
+ if (wav.byteLength < 44) return 0;
929
+ const bytesPerSecond = new DataView(wav.buffer, wav.byteOffset, wav.byteLength).getUint32(28, true);
930
+ return bytesPerSecond > 0 ? (wav.byteLength - 44) / bytesPerSecond : 0;
931
+ }
932
+ /** Распознать запись (WAV). Язык — код ISO-639-1; без него RouterAI определяет язык сам. */
933
+ async function recognizeSpeech(apiBase, apiKey, request, signal) {
934
+ const payload = await post(apiBase, "/audio/transcriptions", apiKey, {
935
+ model: request.model,
936
+ input_audio: {
937
+ data: Buffer.from(request.wav).toString("base64"),
938
+ format: "wav"
939
+ },
940
+ ...request.language === void 0 ? {} : { language: request.language }
941
+ }, signal, 12e4);
942
+ const heard = { text: typeof payload["text"] === "string" ? payload["text"].trim() : "" };
943
+ const seconds = payload["usage"]?.seconds;
944
+ if (typeof seconds === "number" && seconds > 0) heard.seconds = seconds;
945
+ const price = cost(payload);
946
+ if (price !== void 0) heard.cost = price;
947
+ return heard;
948
+ }
811
949
  // src/money.ts
812
950
  /** Сколько знаков после запятой показать, чтобы сумма не выглядела нулём и не шумела копейками. */
813
951
  function fractionDigits(amount) {
@@ -835,9 +973,157 @@ function windowLabel(tokens) {
835
973
  if (tokens >= 1e6) return `${Math.round(tokens / 1e5) / 10}M`;
836
974
  return `${Math.round(tokens / 1e3)}K`;
837
975
  }
976
+ // src/draw.ts
977
+ const MOST_PICTURES = 4;
978
+ const EXTENSIONS = {
979
+ "image/png": "png",
980
+ "image/jpeg": "jpg",
981
+ "image/webp": "webp",
982
+ "image/gif": "gif"
983
+ };
984
+ /** Имя файла из пожелания модели или из описания картинки: латиница, цифры, дефисы. */
985
+ function fileStem(wish, prompt, now = /* @__PURE__ */ new Date()) {
986
+ const base = (wish ?? "").toLowerCase().replace(/\.[a-z0-9]+$/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
987
+ if (base !== "") return base;
988
+ const stamp = now.toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
989
+ const words = prompt.toLowerCase().match(/[a-z0-9]+/g)?.slice(0, 4).join("-") ?? "";
990
+ return words === "" ? `image-${stamp}` : `${words.slice(0, 40)}-${stamp}`;
991
+ }
992
+ function summary(drawn) {
993
+ const count = Math.max(drawn.files.length, drawn.shown.length);
994
+ const parts = [`Готово: ${count === 1 ? "картинка" : `картинок: ${count}`}, модель ${drawn.model}`];
995
+ if (drawn.cost !== null) parts.push(`стоимость ${rub(drawn.cost)}`);
996
+ if (drawn.files.length > 0) parts.push(`файлы: ${drawn.files.join(", ")}`);
997
+ return `${parts.join("; ")}.`;
998
+ }
999
+ const pictures = (shown) => shown.map((attachment) => ({
1000
+ type: "image",
1001
+ attachment
1002
+ }));
1003
+ function registerDrawTool(ctx, deps) {
1004
+ const tool = defineTool({
1005
+ name: DRAW_TOOL,
1006
+ description: `Нарисовать картинку по текстовому описанию (иллюстрация, логотип, макет, схема). Картинка появится в чате и сохранится в папку ${MEDIA_FOLDER} рабочего каталога. Вызов платный — используй, когда пользователь просит изображение.`,
1007
+ parameters: {
1008
+ prompt: {
1009
+ type: "string",
1010
+ required: true,
1011
+ description: "Подробное описание картинки: что изображено, стиль, цвета, композиция. Надписи на картинке указывай дословно."
1012
+ },
1013
+ aspect_ratio: {
1014
+ type: "string",
1015
+ description: "Соотношение сторон, например 1:1, 16:9, 9:16, 4:3. По умолчанию выбирает модель."
1016
+ },
1017
+ count: {
1018
+ type: "integer",
1019
+ description: `Сколько вариантов нарисовать, от 1 до ${MOST_PICTURES}. По умолчанию 1.`
1020
+ },
1021
+ file_name: {
1022
+ type: "string",
1023
+ description: "Имя файла без расширения, латиницей. По умолчанию составляется из описания."
1024
+ }
1025
+ },
1026
+ output: {
1027
+ schema: {
1028
+ type: "object",
1029
+ additionalProperties: false,
1030
+ properties: {
1031
+ model: {
1032
+ type: "string",
1033
+ required: true
1034
+ },
1035
+ cost: {
1036
+ oneOf: [{ type: "number" }, { type: "null" }],
1037
+ required: true
1038
+ },
1039
+ files: {
1040
+ type: "array",
1041
+ items: { type: "string" },
1042
+ required: true
1043
+ },
1044
+ shown: {
1045
+ type: "array",
1046
+ items: { type: "json" },
1047
+ required: true
1048
+ }
1049
+ }
1050
+ },
1051
+ render: (_args, value) => {
1052
+ const drawn = value;
1053
+ return [{
1054
+ type: "text",
1055
+ text: summary(drawn)
1056
+ }, ...pictures(drawn.shown)];
1057
+ },
1058
+ presentationMeta: (_args, value) => {
1059
+ const drawn = value;
1060
+ return {
1061
+ model: drawn.model,
1062
+ cost: drawn.cost,
1063
+ files: drawn.files
1064
+ };
1065
+ }
1066
+ },
1067
+ timeoutMs: 3e5,
1068
+ isConcurrencySafe: () => true,
1069
+ presentCall: (args) => ({
1070
+ card: "generic",
1071
+ kind: "other",
1072
+ title: `Рисую: ${args.prompt.length > 80 ? `${args.prompt.slice(0, 79)}…` : args.prompt}`
1073
+ }),
1074
+ presentResult: (_args, result) => ({
1075
+ card: "generic",
1076
+ title: "Картинка RouterAI",
1077
+ content: result.content.filter((block) => block.type === "image" || block.type === "text")
1078
+ }),
1079
+ execute: async (args, run) => {
1080
+ const model = deps.imageModel();
1081
+ if (model === null) throw new Error("Рисование картинок выключено в Настройках → RouterAI.");
1082
+ const key = await deps.readKey();
1083
+ if (key === void 0) throw new Error("Нет ключа RouterAI. Войдите: Настройки → RouterAI или команда /routerai login.");
1084
+ const count = Math.min(MOST_PICTURES, Math.max(1, args.count ?? 1));
1085
+ const result = await drawPictures(deps.apiBase, key, {
1086
+ model,
1087
+ prompt: args.prompt,
1088
+ count,
1089
+ ...args.aspect_ratio === void 0 ? {} : { aspectRatio: args.aspect_ratio }
1090
+ }, run.signal);
1091
+ const stem = fileStem(args.file_name, args.prompt);
1092
+ const names = result.pictures.map((picture, index) => `${stem}${result.pictures.length > 1 ? `-${index + 1}` : ""}.${EXTENSIONS[picture.type]}`);
1093
+ const files = [];
1094
+ const workingDirectory = ctx.get("workingDirectory");
1095
+ if (workingDirectory !== void 0 && run.agent !== void 0) {
1096
+ const folder = join(await workingDirectory.ensure(run.agent, run.signal), MEDIA_FOLDER);
1097
+ await mkdir(folder, { recursive: true });
1098
+ for (const [index, picture] of result.pictures.entries()) {
1099
+ const path = join(folder, names[index]);
1100
+ await writeFile(path, picture.bytes);
1101
+ files.push(path);
1102
+ }
1103
+ }
1104
+ const shown = [];
1105
+ const attachments = deps.attachments();
1106
+ if (attachments !== void 0) for (const [index, picture] of result.pictures.entries()) shown.push(await attachments.saveImage({
1107
+ data: picture.bytes,
1108
+ mediaType: picture.type,
1109
+ name: names[index]
1110
+ }));
1111
+ if (result.cost !== void 0 && run.agent !== void 0) deps.charged(String(run.agent.id), model, result.cost);
1112
+ return {
1113
+ model,
1114
+ cost: result.cost ?? null,
1115
+ files,
1116
+ shown
1117
+ };
1118
+ }
1119
+ });
1120
+ ctx.effect(() => ctx.tools.register(tool, { when: () => deps.imageModel() !== null }), "routerai: draw tool");
1121
+ }
838
1122
  const INITIAL = {
839
1123
  utilityModel: "deepseek/deepseek-v4.1-flash",
840
- lowBalance: 100
1124
+ lowBalance: 100,
1125
+ imageModel: "bytedance-seed/seedream-5-5",
1126
+ speechModel: "openai/whisper-large-v3-turbo"
841
1127
  };
842
1128
  /** Привести список моделей к каноническому виду или объяснить, что с ним не так. */
843
1129
  function cleanModelList(value) {
@@ -859,6 +1145,8 @@ function fromDisk(raw) {
859
1145
  } catch {}
860
1146
  if (stored["utilityModel"] === null || isModelSlug(stored["utilityModel"])) result.utilityModel = stored["utilityModel"];
861
1147
  if (typeof stored["lowBalance"] === "number" && stored["lowBalance"] >= 0) result.lowBalance = stored["lowBalance"];
1148
+ if (stored["imageModel"] === null || isModelSlug(stored["imageModel"])) result.imageModel = stored["imageModel"];
1149
+ if (isModelSlug(stored["speechModel"])) result.speechModel = stored["speechModel"];
862
1150
  return result;
863
1151
  }
864
1152
  var PreferenceStore = class {
@@ -906,6 +1194,16 @@ var PreferenceStore = class {
906
1194
  if (typeof threshold !== "number" || !Number.isFinite(threshold) || threshold < 0) throw new Error("Порог баланса: ожидалось неотрицательное число");
907
1195
  next.lowBalance = threshold;
908
1196
  }
1197
+ if ("imageModel" in patch) {
1198
+ const model = patch["imageModel"];
1199
+ if (model !== null && !isModelSlug(model)) throw new Error("Модель для картинок: ожидался идентификатор vendor/model или null");
1200
+ next.imageModel = model;
1201
+ }
1202
+ if ("speechModel" in patch) {
1203
+ const model = patch["speechModel"];
1204
+ if (!isModelSlug(model)) throw new Error("Модель распознавания речи: ожидался идентификатор vendor/model");
1205
+ next.speechModel = model;
1206
+ }
909
1207
  await this.file.write(next);
910
1208
  this.value = next;
911
1209
  this.notify();
@@ -976,7 +1274,8 @@ function describeUnknownModel(slug, apiBase) {
976
1274
  alias: slug.startsWith("~"),
977
1275
  tools: true,
978
1276
  reasoning: false,
979
- vision: false
1277
+ vision: false,
1278
+ makes: ["text"]
980
1279
  }, apiBase);
981
1280
  }
982
1281
  /**
@@ -1103,7 +1402,7 @@ function registerSlashCommand(ctx, deps) {
1103
1402
  case "find": {
1104
1403
  await deps.catalog.sync();
1105
1404
  const needle = argument.toLowerCase();
1106
- const hits = deps.catalog.all().filter((card) => `${card.slug} ${card.maker} ${card.title}`.toLowerCase().includes(needle));
1405
+ const hits = deps.catalog.all().filter(isChatModel).filter((card) => `${card.slug} ${card.maker} ${card.title}`.toLowerCase().includes(needle));
1107
1406
  if (hits.length === 0) return bad(`По запросу «${argument}» в каталоге RouterAI ничего нет.`);
1108
1407
  const shown = hits.slice(0, 30).map(modelLine);
1109
1408
  if (hits.length > shown.length) shown.push(`…и ещё ${hits.length - shown.length}. Уточните запрос.`);
@@ -1130,6 +1429,55 @@ function registerSlashCommand(ctx, deps) {
1130
1429
  }
1131
1430
  });
1132
1431
  }
1432
+ // src/speech.ts
1433
+ const SPEECH_PROVIDER = "routerai";
1434
+ /** Языки, которые предлагает выбор в интерфейсе; `auto` оставляет определение языка модели. */
1435
+ const LANGUAGES = [
1436
+ "auto",
1437
+ "ru",
1438
+ "en",
1439
+ "de",
1440
+ "fr",
1441
+ "es",
1442
+ "it",
1443
+ "pt",
1444
+ "tr",
1445
+ "uk",
1446
+ "kk",
1447
+ "zh",
1448
+ "ja",
1449
+ "ko"
1450
+ ];
1451
+ function registerSpeechProvider(ctx, deps) {
1452
+ ctx.effect(() => {
1453
+ const unregister = ctx.speechToText.register({
1454
+ info: {
1455
+ id: SPEECH_PROVIDER,
1456
+ name: "RouterAI",
1457
+ location: "cloud",
1458
+ languages: LANGUAGES
1459
+ },
1460
+ transcribe: async (input, signal) => {
1461
+ const key = await deps.readKey();
1462
+ if (key === void 0) throw new Error("Нет ключа RouterAI. Войдите: Настройки → RouterAI или команда /routerai login.");
1463
+ const started = Date.now();
1464
+ const heard = await recognizeSpeech(deps.apiBase, key, {
1465
+ model: deps.speechModel(),
1466
+ wav: input.audio,
1467
+ ...input.language === "auto" || input.language === "" ? {} : { language: input.language }
1468
+ }, signal);
1469
+ return {
1470
+ text: heard.text,
1471
+ audioSeconds: heard.seconds ?? wavSeconds(input.audio),
1472
+ inferenceSeconds: (Date.now() - started) / 1e3
1473
+ };
1474
+ }
1475
+ });
1476
+ return () => {
1477
+ unregister();
1478
+ };
1479
+ }, "routerai: speech provider");
1480
+ }
1133
1481
  /** Что видит в пикере новый пользователь. */
1134
1482
  const STARTER_MODELS = [
1135
1483
  {
@@ -1165,7 +1513,8 @@ const STARTER_MODELS = [
1165
1513
  *
1166
1514
  * Плагин регистрирует маршрут `routerai`: запросы уходят в OpenAI-совместимый API RouterAI
1167
1515
  * через штатный адаптер pi-ai. Сверх этого плагин следит за каталогом моделей и ценами,
1168
- * помогает получить ключ через браузер, показывает баланс и считает стоимость сессий.
1516
+ * помогает получить ключ через браузер, показывает баланс и считает стоимость сессий,
1517
+ * даёт агенту инструмент для рисования картинок и распознаёт речь для голосового ввода.
1169
1518
  *
1170
1519
  * Настройка из файла (обычно всё делается в Настройках → RouterAI):
1171
1520
  *
@@ -1231,11 +1580,11 @@ function apply(ctx, config) {
1231
1580
  const models = [];
1232
1581
  for (const slug of wanted) {
1233
1582
  const card = catalog.find(slug);
1234
- if (card !== void 0) models.push(describeModel(card, apiBase));
1583
+ if (card !== void 0 && isChatModel(card)) models.push(describeModel(card, apiBase));
1235
1584
  else if (catalog.size === 0) models.push(describeUnknownModel(slug, apiBase));
1236
1585
  else if (!warnedAbout.has(slug)) {
1237
1586
  warnedAbout.add(slug);
1238
- ctx.logger.warn(`[routerai] модели ${slug} нет в каталоге RouterAI — в пикер она не попадёт`);
1587
+ ctx.logger.warn(`[routerai] ${slug}: в каталоге RouterAI нет такой чат-модели — в пикер она не попадёт`);
1239
1588
  }
1240
1589
  }
1241
1590
  route = /* @__PURE__ */ new Map([[ROUTE, buildRoute(models, apiBase, credential)]]);
@@ -1339,6 +1688,24 @@ function apply(ctx, config) {
1339
1688
  requestBody: "buffered"
1340
1689
  }), `routerai: ${endpoint.path}`);
1341
1690
  });
1691
+ ctx.inject(["tools"], (scope) => {
1692
+ registerDrawTool(scope, {
1693
+ apiBase,
1694
+ readKey,
1695
+ imageModel: () => preferences.current.imageModel,
1696
+ attachments: () => optional.attachments,
1697
+ charged: (sessionId, model, rub) => {
1698
+ costs.addCharge(sessionId, model, rub);
1699
+ }
1700
+ });
1701
+ });
1702
+ ctx.inject(["speechToText"], (scope) => {
1703
+ registerSpeechProvider(scope, {
1704
+ apiBase,
1705
+ readKey,
1706
+ speechModel: () => preferences.current.speechModel
1707
+ });
1708
+ });
1342
1709
  ctx.inject(["commands"], (scope) => {
1343
1710
  registerSlashCommand(scope, {
1344
1711
  apiBase,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@router-ai/dsh-plugin-routerai",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "RouterAI — провайдер моделей для DeepSeek Harness: сотни LLM по одному ключу с оплатой в рублях",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -52,17 +52,23 @@
52
52
  "@deepseek-ai/dsh-attachment": "0.2.2-rc.3",
53
53
  "@deepseek-ai/dsh-client-connection": "0.2.2-rc.3",
54
54
  "@deepseek-ai/dsh-client-locale": "0.2.2-rc.3",
55
+ "@deepseek-ai/dsh-client-ui-chat": "0.2.2-rc.3",
56
+ "@deepseek-ai/dsh-client-ui-conversation": "0.2.2-rc.3",
55
57
  "@deepseek-ai/dsh-client-ui-primitives": "0.2.2-rc.3",
56
58
  "@deepseek-ai/dsh-client-ui-renderer": "0.2.2-rc.3",
57
59
  "@deepseek-ai/dsh-client-ui-settings": "0.2.2-rc.3",
58
60
  "@deepseek-ai/dsh-client-ui-sidebar": "0.2.2-rc.3",
59
61
  "@deepseek-ai/dsh-client-ui-slots": "0.2.2-rc.3",
62
+ "@deepseek-ai/dsh-client-ui-tool": "0.2.2-rc.3",
60
63
  "@deepseek-ai/dsh-commands": "0.2.2-rc.3",
61
64
  "@deepseek-ai/dsh-credentials": "0.2.2-rc.3",
65
+ "@deepseek-ai/dsh-experimental-speech-to-text": "0.2.2-rc.3",
62
66
  "@deepseek-ai/dsh-fs": "0.2.2-rc.3",
63
67
  "@deepseek-ai/dsh-launch-environment": "0.2.2-rc.3",
64
68
  "@deepseek-ai/dsh-llm": "0.2.2-rc.3",
65
69
  "@deepseek-ai/dsh-llm-pi-ai": "0.2.2-rc.3",
70
+ "@deepseek-ai/dsh-tools": "0.2.2-rc.3",
71
+ "@deepseek-ai/dsh-working-directory": "0.2.2-rc.3",
66
72
  "@deepseek-ai/schemastery": "3.19.0",
67
73
  "@earendil-works/pi-ai": "1.3.0",
68
74
  "@types/node": "24.19.2",
@@ -78,10 +84,13 @@
78
84
  "@deepseek-ai/dsh-attachment": ">=0.2.2-rc.1 <0.3.0",
79
85
  "@deepseek-ai/dsh-commands": ">=0.2.2-rc.1 <0.3.0",
80
86
  "@deepseek-ai/dsh-credentials": ">=0.2.2-rc.1 <0.3.0",
87
+ "@deepseek-ai/dsh-experimental-speech-to-text": ">=0.2.2-rc.1 <0.3.0",
81
88
  "@deepseek-ai/dsh-fs": ">=0.2.2-rc.1 <0.3.0",
82
89
  "@deepseek-ai/dsh-launch-environment": ">=0.2.2-rc.1 <0.3.0",
83
90
  "@deepseek-ai/dsh-llm": ">=0.2.2-rc.1 <0.3.0",
84
91
  "@deepseek-ai/dsh-llm-pi-ai": ">=0.2.2-rc.1 <0.3.0",
92
+ "@deepseek-ai/dsh-tools": ">=0.2.2-rc.1 <0.3.0",
93
+ "@deepseek-ai/dsh-working-directory": ">=0.2.2-rc.1 <0.3.0",
85
94
  "@deepseek-ai/schemastery": "~3.19.0",
86
95
  "@earendil-works/pi-ai": "^1.3.0"
87
96
  },
@@ -97,6 +106,15 @@
97
106
  },
98
107
  "@deepseek-ai/dsh-commands": {
99
108
  "optional": true
109
+ },
110
+ "@deepseek-ai/dsh-tools": {
111
+ "optional": true
112
+ },
113
+ "@deepseek-ai/dsh-working-directory": {
114
+ "optional": true
115
+ },
116
+ "@deepseek-ai/dsh-experimental-speech-to-text": {
117
+ "optional": true
100
118
  }
101
119
  },
102
120
  "repository": {