@machine202502/dsh-choice 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +99 -2
- package/README.ru.md +99 -0
- package/client.js +569 -0
- package/cordis.patch.yml +5 -0
- package/icon.svg +9 -0
- package/index.js +282 -0
- package/locale/en.json +6 -0
- package/locale/ru.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +42 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 machine202502
|
|
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,100 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-choice
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A single-choice board inside the DeepSeek Harness chat, next to the built-in `ask_user_question`.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
ask_user_choice({ title, subtitle?, options, noneLabel? })
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Every option is **text**, an **image** or a **sound**. The card is posted as a normal chat
|
|
10
|
+
row, the user presses the check icon on one row, and the choice comes back as an ordinary
|
|
11
|
+
chat message that starts the next turn.
|
|
12
|
+
|
|
13
|
+
## Behaviour
|
|
14
|
+
|
|
15
|
+
- **one pick per board** — the check button on the chosen row; other rows become inert;
|
|
16
|
+
- **«none of these»** — a trailing row with its label from `noneLabel`;
|
|
17
|
+
- sounds and image previews stay usable after the pick;
|
|
18
|
+
- **non-blocking** — the tool returns immediately, unlike `ask_user_question`, which waits
|
|
19
|
+
for the answer inside the turn;
|
|
20
|
+
- **subagents are refused** — only the main agent can show a board:
|
|
21
|
+
`human interaction is unavailable while the calling agent is owned by another live agent…`.
|
|
22
|
+
|
|
23
|
+
All visible text (title, subtitle, option titles and descriptions, `noneLabel`) comes from
|
|
24
|
+
the agent, in the language of the conversation.
|
|
25
|
+
|
|
26
|
+
## Card
|
|
27
|
+
|
|
28
|
+
Rows: a 52 px preview (image thumbnail or ▶ button), the title and description, and the
|
|
29
|
+
round check button on the right. Clicking an image opens a lightbox; pressing ▶ plays the
|
|
30
|
+
sound with a plain `Audio` object, so there is no extra UI. A pick replaces the drafts
|
|
31
|
+
carefully: the composer keeps its text, the choice is sent as its own message.
|
|
32
|
+
|
|
33
|
+
## Options
|
|
34
|
+
|
|
35
|
+
```jsonc
|
|
36
|
+
{
|
|
37
|
+
"kind": "text", // required: text | image | audio
|
|
38
|
+
"title": "Keep as is", // row label
|
|
39
|
+
"description": "…", // optional second line
|
|
40
|
+
"value": "…", // text sent to the chat (defaults to title)
|
|
41
|
+
"src": "C:\\sounds\\a.wav" // image/audio: http(s) URL or absolute local path
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Local files are copied into `%LOCALAPPDATA%\dsh-choice\media` and served over loopback.
|
|
46
|
+
|
|
47
|
+
## Bridge
|
|
48
|
+
|
|
49
|
+
`127.0.0.1:49236`:
|
|
50
|
+
|
|
51
|
+
| Method | Path | Purpose |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| `GET` | `/status` | port and media counter |
|
|
54
|
+
| `GET` | `/media/<file>` | served image or audio |
|
|
55
|
+
| `POST` | `/attach` | copy a local file and return its URL |
|
|
56
|
+
| `GET` | `/settings` | stored settings (`language`) |
|
|
57
|
+
| `POST` | `/settings` | save `language`: `ru`, `en`, `zh` or `auto` |
|
|
58
|
+
|
|
59
|
+
## Settings
|
|
60
|
+
|
|
61
|
+
The plugin row config (`plugins.row.config`) holds one control: the interface language —
|
|
62
|
+
Русский, English, 中文 or Авто. It is stored in `%LOCALAPPDATA%\dsh-choice\settings.json`
|
|
63
|
+
and applies to the card and the form without a restart.
|
|
64
|
+
|
|
65
|
+
The language is resolved in this order:
|
|
66
|
+
|
|
67
|
+
1. the saved `language`, when it is `ru`, `en` or `zh`;
|
|
68
|
+
2. `auto` — the application language: `document.documentElement.lang`, then
|
|
69
|
+
`navigator.language`; `ru*` → ru, `zh*` → zh, anything else → en;
|
|
70
|
+
3. nothing stored — `ru`.
|
|
71
|
+
|
|
72
|
+
All card text (title, subtitle, option titles and descriptions, `noneLabel`) comes from the
|
|
73
|
+
agent, in the language of the conversation; only the plugin's own labels — the pick, play
|
|
74
|
+
and open buttons and the settings row — follow the language above.
|
|
75
|
+
|
|
76
|
+
## Languages
|
|
77
|
+
|
|
78
|
+
The interface speaks Russian, English and Chinese. The language comes from the
|
|
79
|
+
extension setting when it is `ru`, `en` or `zh`; `auto` follows the application
|
|
80
|
+
language (`document.documentElement.lang`, then `navigator.language`; `ru*` → ru,
|
|
81
|
+
`zh*` → zh, anything else → en); with nothing stored it is `ru`. Change it in
|
|
82
|
+
**Plugins → Choice**; the value lives in `%LOCALAPPDATA%\dsh-choice\settings.json`
|
|
83
|
+
and applies without a restart.
|
|
84
|
+
|
|
85
|
+
## Screenshots
|
|
86
|
+
|
|
87
|
+

|
|
88
|
+
|
|
89
|
+
## Install
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
"@machine202502/dsh-choice": "^0.1.1"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
and add `dsh-choice` to `dsh.profile.bundles`. A restart is required for the host half to
|
|
96
|
+
load; the client half reloads with the page.
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT.
|
package/README.ru.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# dsh-choice
|
|
2
|
+
|
|
3
|
+
Доска выбора одного варианта внутри чата DeepSeek Harness, рядом со встроенным
|
|
4
|
+
`ask_user_question`.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
ask_user_choice({ title, subtitle?, options, noneLabel? })
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Каждый вариант — **текст**, **картинка** или **звук**. Карточка появляется обычной
|
|
11
|
+
строкой в чате, пользователь нажимает галочку на одном ряду, и выбор приходит
|
|
12
|
+
обычным сообщением, которое начинает следующий ход.
|
|
13
|
+
|
|
14
|
+
## Поведение
|
|
15
|
+
|
|
16
|
+
- **один выбор на доску** — галочка на выбранном ряду, остальные становятся
|
|
17
|
+
неактивными;
|
|
18
|
+
- **«ничего из этого»** — дополнительный ряд, подпись берётся из `noneLabel`;
|
|
19
|
+
- звуки и превью картинок остаются доступными после выбора;
|
|
20
|
+
- **не блокирует** — инструмент возвращается сразу, в отличие от
|
|
21
|
+
`ask_user_question`, который ждёт ответа внутри хода;
|
|
22
|
+
- **сабагентам отказано** — доску показывает только основной агент:
|
|
23
|
+
`human interaction is unavailable while the calling agent is owned by another live agent…`.
|
|
24
|
+
|
|
25
|
+
Весь видимый текст (заголовок, подзаголовок, названия и описания вариантов,
|
|
26
|
+
`noneLabel`) приходит от агента на языке разговора.
|
|
27
|
+
|
|
28
|
+
## Карточка
|
|
29
|
+
|
|
30
|
+
Ряды: превью 52 px (миниатюра картинки или кнопка ▶), заголовок и описание, справа
|
|
31
|
+
круглая кнопка-галочка. Клик по картинке открывает просмотр; ▶ проигрывает звук
|
|
32
|
+
обычным `Audio`, без дополнительного интерфейса. Выбор аккуратно заменяет черновики:
|
|
33
|
+
текст в композере сохраняется, выбор уходит отдельным сообщением.
|
|
34
|
+
|
|
35
|
+
## Варианты
|
|
36
|
+
|
|
37
|
+
```jsonc
|
|
38
|
+
{
|
|
39
|
+
"kind": "text", // обязателен: text | image | audio
|
|
40
|
+
"title": "Оставить как есть",// подпись ряда
|
|
41
|
+
"description": "…", // необязательная вторая строка
|
|
42
|
+
"value": "…", // текст, который уйдёт в чат (по умолчанию title)
|
|
43
|
+
"src": "C:\\sounds\\a.wav" // image/audio: http(s)-ссылка или абсолютный путь
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Локальные файлы копируются в `%LOCALAPPDATA%\dsh-choice\media` и отдаются через
|
|
48
|
+
loopback.
|
|
49
|
+
|
|
50
|
+
## Мост
|
|
51
|
+
|
|
52
|
+
`127.0.0.1:49236`:
|
|
53
|
+
|
|
54
|
+
| Метод | Путь | Назначение |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `GET` | `/status` | порт и счётчик медиа |
|
|
57
|
+
| `GET` | `/media/<file>` | отдача картинки или звука |
|
|
58
|
+
| `POST` | `/attach` | скопировать локальный файл и вернуть ссылку |
|
|
59
|
+
| `GET` | `/settings` | сохранённые настройки (`language`) |
|
|
60
|
+
| `POST` | `/settings` | сохранить `language`: `ru`, `en`, `zh` или `auto` |
|
|
61
|
+
|
|
62
|
+
## Настройки
|
|
63
|
+
|
|
64
|
+
В форме плагина (`plugins.row.config`) один контрол — язык интерфейса: Русский,
|
|
65
|
+
English, 中文 или Авто. Хранится в `%LOCALAPPDATA%\dsh-choice\settings.json` и
|
|
66
|
+
применяется к карточке и форме без перезапуска.
|
|
67
|
+
|
|
68
|
+
Язык выбирается в таком порядке:
|
|
69
|
+
|
|
70
|
+
1. сохранённый `language`, если это `ru`, `en` или `zh`;
|
|
71
|
+
2. `auto` — язык приложения: `document.documentElement.lang`, затем
|
|
72
|
+
`navigator.language`; `ru*` → ru, `zh*` → zh, всё остальное → en;
|
|
73
|
+
3. ничего не сохранено — `ru`.
|
|
74
|
+
|
|
75
|
+
## Языки
|
|
76
|
+
|
|
77
|
+
Интерфейс говорит на русском, английском и китайском. Язык берётся из настройки
|
|
78
|
+
плагина, если это `ru`, `en` или `zh`; `auto` следует языку приложения
|
|
79
|
+
(`document.documentElement.lang`, затем `navigator.language`; `ru*` → ru,
|
|
80
|
+
`zh*` → zh, остальное → en); если ничего не сохранено — `ru`. Меняется в
|
|
81
|
+
**Plugins → Choice**, хранится в `%LOCALAPPDATA%\dsh-choice\settings.json` и
|
|
82
|
+
применяется без перезапуска.
|
|
83
|
+
|
|
84
|
+
## Скриншоты
|
|
85
|
+
|
|
86
|
+

|
|
87
|
+
|
|
88
|
+
## Установка
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
"@machine202502/dsh-choice": "^0.1.1"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
и добавить `dsh-choice` в `dsh.profile.bundles`. Для загрузки хост-части нужен
|
|
95
|
+
перезапуск; клиентская часть перезагружается вместе со страницей.
|
|
96
|
+
|
|
97
|
+
## Лицензия
|
|
98
|
+
|
|
99
|
+
MIT.
|
package/client.js
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: '@machine202502/dsh-choice',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react');
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
|
|
7
|
+
/** Loopback bridge of the host half; the saved language lives behind it. */
|
|
8
|
+
const PORT = 49236;
|
|
9
|
+
const base = `http://127.0.0.1:${PORT}`;
|
|
10
|
+
|
|
11
|
+
const STRINGS = {
|
|
12
|
+
ru: {
|
|
13
|
+
select: 'Выбрать',
|
|
14
|
+
play: 'Воспроизвести',
|
|
15
|
+
open: 'Открыть',
|
|
16
|
+
option: 'Вариант',
|
|
17
|
+
settingsTitle: 'Язык',
|
|
18
|
+
settingsHint: 'Авто — по языку приложения',
|
|
19
|
+
save: 'Сохранить',
|
|
20
|
+
saved: 'Сохранено',
|
|
21
|
+
saveFailed: 'Не удалось сохранить',
|
|
22
|
+
loading: 'Загрузка…',
|
|
23
|
+
auto: 'Авто',
|
|
24
|
+
langRu: 'Русский',
|
|
25
|
+
langEn: 'English',
|
|
26
|
+
langZh: '中文',
|
|
27
|
+
},
|
|
28
|
+
en: {
|
|
29
|
+
select: 'Select',
|
|
30
|
+
play: 'Play',
|
|
31
|
+
open: 'Open',
|
|
32
|
+
option: 'Option',
|
|
33
|
+
settingsTitle: 'Language',
|
|
34
|
+
settingsHint: 'Auto follows the application language',
|
|
35
|
+
save: 'Save',
|
|
36
|
+
saved: 'Saved',
|
|
37
|
+
saveFailed: 'Could not save',
|
|
38
|
+
loading: 'Loading…',
|
|
39
|
+
auto: 'Auto',
|
|
40
|
+
langRu: 'Русский',
|
|
41
|
+
langEn: 'English',
|
|
42
|
+
langZh: '中文',
|
|
43
|
+
},
|
|
44
|
+
zh: {
|
|
45
|
+
select: '选择',
|
|
46
|
+
play: '播放',
|
|
47
|
+
open: '打开',
|
|
48
|
+
option: '选项',
|
|
49
|
+
settingsTitle: '语言',
|
|
50
|
+
settingsHint: '自动 — 跟随应用界面语言',
|
|
51
|
+
save: '保存',
|
|
52
|
+
saved: '已保存',
|
|
53
|
+
saveFailed: '保存失败',
|
|
54
|
+
loading: '加载中…',
|
|
55
|
+
auto: '自动',
|
|
56
|
+
langRu: 'Русский',
|
|
57
|
+
langEn: 'English',
|
|
58
|
+
langZh: '中文',
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** The interface language; the shared default until the settings answer. */
|
|
63
|
+
let uiLanguage = 'ru';
|
|
64
|
+
const languageListeners = new Set();
|
|
65
|
+
const setLanguage = (next) => {
|
|
66
|
+
if (!STRINGS[next] || next === uiLanguage) return;
|
|
67
|
+
uiLanguage = next;
|
|
68
|
+
for (const listener of languageListeners) listener(next);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** The application locale an `auto` preference follows. */
|
|
72
|
+
const detectLanguage = () => {
|
|
73
|
+
const raw = String(
|
|
74
|
+
(typeof document === 'undefined' ? '' : document.documentElement?.lang)
|
|
75
|
+
|| (typeof navigator === 'undefined' ? '' : navigator.language)
|
|
76
|
+
|| 'en',
|
|
77
|
+
).toLowerCase();
|
|
78
|
+
if (raw.startsWith('ru')) return 'ru';
|
|
79
|
+
if (raw.startsWith('zh')) return 'zh';
|
|
80
|
+
return 'en';
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Saved language first, `auto` second, the ru default when nothing is stored. */
|
|
84
|
+
const resolveLanguage = (settings) => {
|
|
85
|
+
const preference = settings?.language;
|
|
86
|
+
if (preference === 'ru' || preference === 'en' || preference === 'zh') return preference;
|
|
87
|
+
if (preference === 'auto') return detectLanguage();
|
|
88
|
+
return 'ru';
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Read the stored language over the loopback bridge; failure keeps the default. */
|
|
92
|
+
const readSettings = async () => {
|
|
93
|
+
const response = await fetch(`${base}/settings`, { cache: 'no-store' });
|
|
94
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
95
|
+
const body = await response.json();
|
|
96
|
+
setLanguage(resolveLanguage(body?.values));
|
|
97
|
+
return body;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// The card may render before the bridge answers; it re-renders on a language change.
|
|
101
|
+
void readSettings().catch(() => {});
|
|
102
|
+
// Another page part (or a second form instance) saved the language: re-read it.
|
|
103
|
+
window.addEventListener('dsh-choice:settings', () => { void readSettings().catch(() => {}); });
|
|
104
|
+
|
|
105
|
+
const ensureStyles = () => {
|
|
106
|
+
if (document.querySelector('style[data-plugin="choice-board"]')) return;
|
|
107
|
+
const tag = document.createElement('style');
|
|
108
|
+
tag.dataset.plugin = 'choice-board';
|
|
109
|
+
tag.textContent = `
|
|
110
|
+
.cb-card { pointer-events: auto; display: flex; flex-direction: column; gap: 2px; padding: 6px 0; margin: 6px 0; width: 100%; box-sizing: border-box;
|
|
111
|
+
border: .5px solid var(--dsw-alias-border-l1, rgba(0,0,0,.10)); border-radius: 16px;
|
|
112
|
+
background: var(--dsw-alias-bg-l1, rgba(0,0,0,.015)); max-width: 640px; overflow: hidden; }
|
|
113
|
+
.cb-head { display: flex; flex-direction: column; gap: 2px; padding: 10px 14px 8px; }
|
|
114
|
+
.cb-title { font-size: 14px; font-weight: 600; }
|
|
115
|
+
.cb-sub { font-size: 12px; opacity: .6; }
|
|
116
|
+
.cb-row { display: flex; align-items: center; gap: 12px; width: 100%; box-sizing: border-box; padding: 10px 14px;
|
|
117
|
+
border-top: .5px solid var(--dsw-alias-border-l1, rgba(0,0,0,.07)); }
|
|
118
|
+
.cb-row--selected { background: rgba(91,140,255,.08); }
|
|
119
|
+
.cb-media { flex: none; width: 52px; height: 52px; border-radius: 10px; overflow: hidden; padding: 0;
|
|
120
|
+
border: none; background: var(--dsw-alias-bg-l2, rgba(0,0,0,.05)); cursor: pointer;
|
|
121
|
+
display: flex; align-items: center; justify-content: center; }
|
|
122
|
+
.cb-media--static { cursor: default; }
|
|
123
|
+
.cb-thumb { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
124
|
+
.cb-play { width: 34px; height: 34px; border-radius: 999px; border: none; background: #0F1720; color: #5B8CFF;
|
|
125
|
+
font-size: 13px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
|
|
126
|
+
.cb-play:disabled { opacity: .5; cursor: default; }
|
|
127
|
+
.cb-row--selected .cb-play { color: #5B8CFF; }
|
|
128
|
+
.cb-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
|
|
129
|
+
.cb-label { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
130
|
+
.cb-desc { font-size: 12px; line-height: 16px; opacity: .6; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
131
|
+
.cb-select { flex: none; width: 28px; height: 28px; padding: 0; border-radius: 999px; border: .5px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
|
|
132
|
+
background: transparent; color: inherit; font-size: 13px; cursor: pointer;
|
|
133
|
+
display: inline-flex; align-items: center; justify-content: center; }
|
|
134
|
+
.cb-select:hover { background: var(--dsw-alias-bg-l2, rgba(0,0,0,.05)); }
|
|
135
|
+
.cb-select[disabled] { cursor: default; opacity: .5; }
|
|
136
|
+
.cb-select--picked { border-color: #5B8CFF; color: #3b6fd8; background: rgba(91,140,255,.10); opacity: 1; }
|
|
137
|
+
.cb-lightbox { position: fixed; inset: 0; z-index: 4000; display: flex; align-items: center; justify-content: center;
|
|
138
|
+
background: rgba(0,0,0,.55); backdrop-filter: blur(2px); }
|
|
139
|
+
.cb-lightbox-card { max-width: min(90vw, 1100px); max-height: 88vh; display: flex; flex-direction: column; gap: 8px;
|
|
140
|
+
background: var(--dsw-alias-bg-l1, #fff); border-radius: 16px; padding: 10px; box-shadow: 0 24px 60px rgba(0,0,0,.35); }
|
|
141
|
+
.cb-lightbox-img { max-width: 100%; max-height: 76vh; object-fit: contain; border-radius: 10px; display: block; }
|
|
142
|
+
.cb-lightbox-cap { font-size: 12px; opacity: .7; padding: 0 4px 2px; }
|
|
143
|
+
.cb-set { display: flex; flex-direction: column; gap: 14px; max-width: 520px; padding: 4px 0 8px; font-size: 13px; }
|
|
144
|
+
.cb-set-row { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
|
|
145
|
+
.cb-set-hint { margin-top: 2px; font-size: 12px; line-height: 16px; opacity: .6; }
|
|
146
|
+
.cb-set-select { height: 32px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l1, #dcdcdc); border-radius: 10px;
|
|
147
|
+
background: var(--dsw-alias-bg-base, #fff); color: inherit; font: inherit; outline: none; }
|
|
148
|
+
.cb-set-actions { display: flex; align-items: center; gap: 12px; }
|
|
149
|
+
.cb-set-save { height: 34px; padding: 0 16px; border: none; border-radius: 10px;
|
|
150
|
+
background: var(--dsw-alias-label-primary, #1f1f1f); color: var(--dsw-alias-bg-base, #fff); font: inherit; cursor: pointer; }
|
|
151
|
+
.cb-set-save:disabled { opacity: .45; cursor: default; }
|
|
152
|
+
.cb-set-status { font-size: 12px; opacity: .6; }
|
|
153
|
+
.cb-set-status--error { color: #c0392b; opacity: 1; }
|
|
154
|
+
`;
|
|
155
|
+
document.head.appendChild(tag);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const isUrl = (value) => /^https?:\/\//i.test(value ?? '');
|
|
159
|
+
/** Cards that already posted their pick in this page session. */
|
|
160
|
+
const sentCards = new Set();
|
|
161
|
+
const ourCalls = new Set();
|
|
162
|
+
|
|
163
|
+
const cardDefinition = {
|
|
164
|
+
kind: 'choice-board',
|
|
165
|
+
target: 'chat',
|
|
166
|
+
match: (event) => {
|
|
167
|
+
if (event?.type === 'tool/call') {
|
|
168
|
+
if (event.data?.name !== 'ask_user_choice') return null;
|
|
169
|
+
const id = String(event.data.callId);
|
|
170
|
+
ourCalls.add(id);
|
|
171
|
+
return { id, role: 'start' };
|
|
172
|
+
}
|
|
173
|
+
if (event?.type === 'tool/result') {
|
|
174
|
+
const callId = event.data?.message?.source?.callId;
|
|
175
|
+
if (typeof callId !== 'string' || !ourCalls.has(String(callId))) return null;
|
|
176
|
+
return { id: String(callId), role: 'update' };
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
},
|
|
180
|
+
start: (_context, match) => {
|
|
181
|
+
let args = {};
|
|
182
|
+
try {
|
|
183
|
+
args = JSON.parse(match.event.data?.arguments ?? '{}');
|
|
184
|
+
} catch {
|
|
185
|
+
args = {};
|
|
186
|
+
}
|
|
187
|
+
return { id: match.id, seq: match.event.seq, args, result: null, media: {} };
|
|
188
|
+
},
|
|
189
|
+
update: (context, match) => {
|
|
190
|
+
const text = (match.event.data?.message?.content ?? [])
|
|
191
|
+
.filter((item) => item && item.type === 'text')
|
|
192
|
+
.map((item) => item.text ?? '')
|
|
193
|
+
.join('\n');
|
|
194
|
+
const media = { ...(context.state.media ?? {}) };
|
|
195
|
+
const matchMedia = /\[media:(\[[\s\S]*\])\]/.exec(text);
|
|
196
|
+
if (matchMedia) {
|
|
197
|
+
try {
|
|
198
|
+
for (const item of JSON.parse(matchMedia[1])) {
|
|
199
|
+
if (typeof item?.index === 'number' && typeof item?.url === 'string') media[item.index] = item.url;
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
// Malformed media map: the rows simply show placeholders.
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return { ...context.state, result: text || null, media };
|
|
206
|
+
},
|
|
207
|
+
buildViewNode: (context) => {
|
|
208
|
+
const state = context.state;
|
|
209
|
+
if (!state) return null;
|
|
210
|
+
return {
|
|
211
|
+
key: context.key,
|
|
212
|
+
kind: 'choice-board',
|
|
213
|
+
id: context.id,
|
|
214
|
+
target: 'chat',
|
|
215
|
+
anchorSeq: state.seq,
|
|
216
|
+
location: context.location ?? { kind: 'chat' },
|
|
217
|
+
visibility: 'visible',
|
|
218
|
+
data: { ...state, nodeKey: context.key },
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const visible = (node) => node instanceof HTMLElement && node.offsetParent !== null;
|
|
224
|
+
|
|
225
|
+
const findSendButton = () => [...document.querySelectorAll('button')]
|
|
226
|
+
.find((button) => /send|submit|отправ/i.test(button.getAttribute('aria-label') ?? button.title ?? ''));
|
|
227
|
+
|
|
228
|
+
const findComposer = () => {
|
|
229
|
+
const areas = [...document.querySelectorAll('[contenteditable="true"], textarea')].filter(visible);
|
|
230
|
+
if (areas.length === 0) return null;
|
|
231
|
+
const placeholder = areas.find((area) => /message|сообщени/i.test(
|
|
232
|
+
area.getAttribute('aria-placeholder') ?? area.getAttribute('data-placeholder') ?? area.getAttribute('placeholder') ?? ''));
|
|
233
|
+
if (placeholder) return placeholder;
|
|
234
|
+
const send = findSendButton();
|
|
235
|
+
if (send) {
|
|
236
|
+
const rect = send.getBoundingClientRect();
|
|
237
|
+
let best = null;
|
|
238
|
+
let bestDistance = Infinity;
|
|
239
|
+
for (const area of areas) {
|
|
240
|
+
const box = area.getBoundingClientRect();
|
|
241
|
+
const distance = Math.abs(box.bottom - rect.bottom) + Math.abs(box.right - rect.right) * 0.1;
|
|
242
|
+
if (distance < bestDistance) { bestDistance = distance; best = area; }
|
|
243
|
+
}
|
|
244
|
+
if (best) return best;
|
|
245
|
+
}
|
|
246
|
+
return areas[areas.length - 1] ?? null;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const composerText = (area) => (area instanceof HTMLTextAreaElement ? area.value : (area.innerText ?? area.textContent ?? '')).trim();
|
|
250
|
+
|
|
251
|
+
const selectAllContents = (area) => {
|
|
252
|
+
const selection = window.getSelection();
|
|
253
|
+
const range = document.createRange();
|
|
254
|
+
range.selectNodeContents(area);
|
|
255
|
+
selection?.removeAllRanges();
|
|
256
|
+
selection?.addRange(range);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const setComposerText = async (area, text) => {
|
|
260
|
+
area.focus();
|
|
261
|
+
await sleep(60);
|
|
262
|
+
if (area instanceof HTMLTextAreaElement) {
|
|
263
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
|
|
264
|
+
setter?.call(area, text);
|
|
265
|
+
area.dispatchEvent(new Event('input', { bubbles: true }));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
selectAllContents(area);
|
|
269
|
+
await sleep(40);
|
|
270
|
+
document.execCommand('delete');
|
|
271
|
+
await sleep(40);
|
|
272
|
+
if (text.length > 0) {
|
|
273
|
+
selectAllContents(area);
|
|
274
|
+
document.execCommand('insertText', false, text);
|
|
275
|
+
}
|
|
276
|
+
await sleep(80);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
280
|
+
|
|
281
|
+
const sendMessage = async (text) => {
|
|
282
|
+
const send = findSendButton();
|
|
283
|
+
const area = findComposer();
|
|
284
|
+
if (!area) return { ok: false, debug: 'no composer' };
|
|
285
|
+
const draft = composerText(area);
|
|
286
|
+
let inserted = false;
|
|
287
|
+
|
|
288
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
289
|
+
await setComposerText(area, text);
|
|
290
|
+
if (composerText(area) === text.trim()) { inserted = true; break; }
|
|
291
|
+
if (attempt === 1) {
|
|
292
|
+
area.textContent = '';
|
|
293
|
+
area.dispatchEvent(new Event('input', { bubbles: true }));
|
|
294
|
+
await sleep(60);
|
|
295
|
+
area.textContent = text;
|
|
296
|
+
area.dispatchEvent(new Event('input', { bubbles: true }));
|
|
297
|
+
await sleep(140);
|
|
298
|
+
if (composerText(area) === text.trim()) { inserted = true; break; }
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!inserted) {
|
|
303
|
+
return { ok: false };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (send) send.click();
|
|
307
|
+
else area.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true }));
|
|
308
|
+
|
|
309
|
+
await sleep(180);
|
|
310
|
+
if (draft && composerText(area) === '') {
|
|
311
|
+
await setComposerText(area, draft);
|
|
312
|
+
}
|
|
313
|
+
return { ok: true };
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
function ChoiceCard(props) {
|
|
318
|
+
ensureStyles();
|
|
319
|
+
const [language, setLanguageState] = React.useState(uiLanguage);
|
|
320
|
+
const t = STRINGS[language] ?? STRINGS.ru;
|
|
321
|
+
const data = props?.node?.data ?? {};
|
|
322
|
+
const args = data.args ?? {};
|
|
323
|
+
const callId = data.seq !== undefined && data.seq !== null ? String(data.seq) : (data.nodeKey ?? data.id ?? 'board');
|
|
324
|
+
const options = Array.isArray(args.options) ? args.options : [];
|
|
325
|
+
const [loaded, setLoaded] = React.useState({});
|
|
326
|
+
const [picked, setPicked] = React.useState(() => (sentCards.has(callId) ? '__sent__' : null));
|
|
327
|
+
const [playing, setPlaying] = React.useState(null);
|
|
328
|
+
const [zoom, setZoom] = React.useState(null);
|
|
329
|
+
const audioRef = React.useRef(null);
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
React.useEffect(() => {
|
|
333
|
+
languageListeners.add(setLanguageState);
|
|
334
|
+
setLanguageState(uiLanguage);
|
|
335
|
+
return () => { languageListeners.delete(setLanguageState); };
|
|
336
|
+
}, []);
|
|
337
|
+
React.useEffect(() => () => { audioRef.current?.pause?.(); }, []);
|
|
338
|
+
React.useEffect(() => {
|
|
339
|
+
if (!zoom) return undefined;
|
|
340
|
+
const onKey = (event) => { if (event.key === 'Escape') setZoom(null); };
|
|
341
|
+
document.addEventListener('keydown', onKey);
|
|
342
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
343
|
+
}, [zoom]);
|
|
344
|
+
|
|
345
|
+
const sourceOf = (option, index) => (isUrl(option?.src) ? option.src : (data.media?.[index] ?? loaded[index] ?? null));
|
|
346
|
+
const labelOf = (option, index) => option?.title ?? option?.id ?? `${t.option} ${index + 1}`;
|
|
347
|
+
const valueOf = (option, index) => (typeof option?.value === 'string' ? option.value : labelOf(option, index));
|
|
348
|
+
|
|
349
|
+
const choose = async (option, index, none) => {
|
|
350
|
+
if (picked || sentCards.has(callId)) return;
|
|
351
|
+
sentCards.add(callId);
|
|
352
|
+
const text = none ? (args.noneLabel || '—') : valueOf(option, index);
|
|
353
|
+
const id = none ? '__none__' : String(option?.id ?? `option-${index + 1}`);
|
|
354
|
+
setPicked(id);
|
|
355
|
+
await sendMessage(text);
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const togglePlay = (index, source) => {
|
|
359
|
+
if (!source) return;
|
|
360
|
+
if (audioRef.current && playing === index) {
|
|
361
|
+
audioRef.current.pause();
|
|
362
|
+
setPlaying(null);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
audioRef.current?.pause?.();
|
|
366
|
+
const audio = new Audio(source);
|
|
367
|
+
audioRef.current = audio;
|
|
368
|
+
audio.onended = () => setPlaying(null);
|
|
369
|
+
void audio.play();
|
|
370
|
+
setPlaying(index);
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const selectButton = (selected, disabled, onClick, meta) => h('button', {
|
|
374
|
+
type: 'button',
|
|
375
|
+
className: `cb-select${selected ? ' cb-select--picked' : ''}`,
|
|
376
|
+
disabled,
|
|
377
|
+
title: t.select,
|
|
378
|
+
'aria-label': t.select,
|
|
379
|
+
'data-cb': 'pick',
|
|
380
|
+
'data-cb-value': meta?.value ?? '',
|
|
381
|
+
'data-cb-id': meta?.id ?? '',
|
|
382
|
+
'data-cb-none': meta?.none ? '1' : '0',
|
|
383
|
+
onPointerDown: (event) => { event.stopPropagation(); event.preventDefault(); onClick(); },
|
|
384
|
+
onClick: (event) => { event.stopPropagation(); onClick(); },
|
|
385
|
+
}, '✓');
|
|
386
|
+
|
|
387
|
+
const rows = options.map((option, index) => {
|
|
388
|
+
const id = String(option?.id ?? `option-${index + 1}`);
|
|
389
|
+
const selected = picked === id;
|
|
390
|
+
const kind = option?.kind ?? 'text';
|
|
391
|
+
const source = sourceOf(option, index);
|
|
392
|
+
let mediaBlock = null;
|
|
393
|
+
if (kind === 'image') {
|
|
394
|
+
mediaBlock = h('button', {
|
|
395
|
+
className: 'cb-media',
|
|
396
|
+
type: 'button',
|
|
397
|
+
'data-cb': 'image',
|
|
398
|
+
'data-cb-src': source ?? '',
|
|
399
|
+
'data-cb-title': labelOf(option, index),
|
|
400
|
+
title: t.open,
|
|
401
|
+
'aria-label': t.open,
|
|
402
|
+
onClick: (event) => { event.stopPropagation(); if (source) setZoom({ src: source, title: labelOf(option, index), desc: option?.description }); },
|
|
403
|
+
}, source ? h('img', { className: 'cb-thumb', src: source, alt: '' }) : null);
|
|
404
|
+
} else if (kind === 'audio') {
|
|
405
|
+
mediaBlock = h('button', {
|
|
406
|
+
className: 'cb-media',
|
|
407
|
+
type: 'button',
|
|
408
|
+
'data-cb': 'play',
|
|
409
|
+
'data-cb-src': source ?? '',
|
|
410
|
+
title: t.play,
|
|
411
|
+
'aria-label': t.play,
|
|
412
|
+
disabled: !source,
|
|
413
|
+
onClick: (event) => { event.stopPropagation(); togglePlay(index, source); },
|
|
414
|
+
}, h('span', { className: 'cb-play' }, playing === index ? '❚❚' : '▶'));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return h('div', { key: id, className: `cb-row${selected ? ' cb-row--selected' : ''}` },
|
|
418
|
+
mediaBlock,
|
|
419
|
+
h('span', { className: 'cb-text' },
|
|
420
|
+
h('span', { className: 'cb-label' }, labelOf(option, index)),
|
|
421
|
+
option?.description ? h('span', { className: 'cb-desc' }, option.description) : null,
|
|
422
|
+
),
|
|
423
|
+
selectButton(selected, Boolean(picked), () => void choose(option, index, false), { value: valueOf(option, index), id }),
|
|
424
|
+
);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const noneSelected = picked === '__none__';
|
|
428
|
+
const noneRow = h('div', { className: `cb-row${noneSelected ? ' cb-row--selected' : ''}` },
|
|
429
|
+
h('span', { className: 'cb-text' }, h('span', { className: 'cb-label' }, args.noneLabel || '—')),
|
|
430
|
+
selectButton(noneSelected, Boolean(picked), () => void choose(null, -1, true), { value: args.noneLabel || '', id: '__none__', none: true }),
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
const lightbox = zoom
|
|
434
|
+
? h('div', {
|
|
435
|
+
className: 'cb-lightbox',
|
|
436
|
+
onClick: () => setZoom(null),
|
|
437
|
+
},
|
|
438
|
+
h('div', { className: 'cb-lightbox-card', onClick: (event) => event.stopPropagation() },
|
|
439
|
+
h('img', { className: 'cb-lightbox-img', src: zoom.src, alt: zoom.title }),
|
|
440
|
+
(zoom.title || zoom.desc)
|
|
441
|
+
? h('span', { className: 'cb-lightbox-cap' }, [zoom.title, zoom.desc].filter(Boolean).join(' — '))
|
|
442
|
+
: null,
|
|
443
|
+
),
|
|
444
|
+
)
|
|
445
|
+
: null;
|
|
446
|
+
|
|
447
|
+
return h('div', { className: 'cb-card' },
|
|
448
|
+
h('div', { className: 'cb-head' },
|
|
449
|
+
args.title ? h('span', { className: 'cb-title' }, args.title) : null,
|
|
450
|
+
args.subtitle ? h('span', { className: 'cb-sub' }, args.subtitle) : null,
|
|
451
|
+
),
|
|
452
|
+
...rows,
|
|
453
|
+
noneRow,
|
|
454
|
+
lightbox,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* The settings row: one language select, saved to the host bridge so the
|
|
460
|
+
* board and the form agree without a restart.
|
|
461
|
+
*/
|
|
462
|
+
function ChoiceSettings() {
|
|
463
|
+
ensureStyles();
|
|
464
|
+
const [state, setState] = React.useState({ status: 'loading', values: {}, defaults: {} });
|
|
465
|
+
const [saving, setSaving] = React.useState(false);
|
|
466
|
+
const [notice, setNotice] = React.useState({ text: '', error: false });
|
|
467
|
+
const [language, setLanguageState] = React.useState(uiLanguage);
|
|
468
|
+
|
|
469
|
+
React.useEffect(() => {
|
|
470
|
+
let alive = true;
|
|
471
|
+
languageListeners.add(setLanguageState);
|
|
472
|
+
readSettings()
|
|
473
|
+
.then((body) => {
|
|
474
|
+
if (alive) setState({ status: 'ready', values: body?.values ?? {}, defaults: body?.defaults ?? {} });
|
|
475
|
+
})
|
|
476
|
+
.catch(() => {
|
|
477
|
+
if (alive) setState({ status: 'error', values: {}, defaults: {} });
|
|
478
|
+
});
|
|
479
|
+
return () => {
|
|
480
|
+
alive = false;
|
|
481
|
+
languageListeners.delete(setLanguageState);
|
|
482
|
+
};
|
|
483
|
+
}, []);
|
|
484
|
+
|
|
485
|
+
const t = STRINGS[language] ?? STRINGS.ru;
|
|
486
|
+
const chosen = state.values.language ?? state.defaults.language ?? 'ru';
|
|
487
|
+
|
|
488
|
+
const save = async () => {
|
|
489
|
+
setSaving(true);
|
|
490
|
+
setNotice({ text: '', error: false });
|
|
491
|
+
try {
|
|
492
|
+
const response = await fetch(`${base}/settings`, {
|
|
493
|
+
method: 'POST',
|
|
494
|
+
headers: { 'content-type': 'application/json' },
|
|
495
|
+
body: JSON.stringify({ language: chosen }),
|
|
496
|
+
});
|
|
497
|
+
const body = await response.json();
|
|
498
|
+
if (!response.ok) throw new Error(body.error ?? `HTTP ${response.status}`);
|
|
499
|
+
setState({ status: 'ready', values: body.values ?? {}, defaults: body.defaults ?? {} });
|
|
500
|
+
setLanguage(resolveLanguage(body?.values));
|
|
501
|
+
setNotice({ text: (STRINGS[uiLanguage] ?? STRINGS.ru).saved, error: false });
|
|
502
|
+
window.dispatchEvent(new CustomEvent('dsh-choice:settings'));
|
|
503
|
+
} catch (error) {
|
|
504
|
+
setNotice({ text: `${(STRINGS[uiLanguage] ?? STRINGS.ru).saveFailed}: ${String(error?.message ?? error)}`, error: true });
|
|
505
|
+
} finally {
|
|
506
|
+
setSaving(false);
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
if (state.status === 'loading') {
|
|
511
|
+
return h('div', { className: 'cb-set' }, h('span', { className: 'cb-set-status' }, t.loading));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const options = [
|
|
515
|
+
['ru', t.langRu],
|
|
516
|
+
['en', t.langEn],
|
|
517
|
+
['zh', t.langZh],
|
|
518
|
+
['auto', t.auto],
|
|
519
|
+
];
|
|
520
|
+
|
|
521
|
+
return h('div', { className: 'cb-set' },
|
|
522
|
+
h('div', { className: 'cb-set-row' },
|
|
523
|
+
h('div', null,
|
|
524
|
+
h('div', null, t.settingsTitle),
|
|
525
|
+
h('div', { className: 'cb-set-hint' }, t.settingsHint),
|
|
526
|
+
),
|
|
527
|
+
h('select', {
|
|
528
|
+
className: 'cb-set-select',
|
|
529
|
+
value: chosen,
|
|
530
|
+
'aria-label': t.settingsTitle,
|
|
531
|
+
onChange: (event) => setState((previous) => ({ ...previous, values: { ...previous.values, language: event.target.value } })),
|
|
532
|
+
}, options.map(([value, text]) => h('option', { key: value, value }, text))),
|
|
533
|
+
),
|
|
534
|
+
h('div', { className: 'cb-set-actions' },
|
|
535
|
+
h('button', { type: 'button', className: 'cb-set-save', onClick: save, disabled: saving === true },
|
|
536
|
+
saving ? '…' : t.save),
|
|
537
|
+
notice.text ? h('span', { className: `cb-set-status${notice.error ? ' cb-set-status--error' : ''}` }, notice.text) : null,
|
|
538
|
+
),
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return {
|
|
543
|
+
inject: ['slots', 'uiConversation'],
|
|
544
|
+
apply(ctx) {
|
|
545
|
+
try {
|
|
546
|
+
ctx.uiConversation.events.register(cardDefinition);
|
|
547
|
+
} catch (error) {
|
|
548
|
+
console.warn('dsh-choice: chat node definition failed', error);
|
|
549
|
+
}
|
|
550
|
+
try {
|
|
551
|
+
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
|
|
552
|
+
name: 'conversation.chat.node',
|
|
553
|
+
key: 'choice-board',
|
|
554
|
+
}, ChoiceCard));
|
|
555
|
+
} catch (error) {
|
|
556
|
+
console.warn('dsh-choice: chat node view failed', error);
|
|
557
|
+
}
|
|
558
|
+
try {
|
|
559
|
+
ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
|
|
560
|
+
name: 'plugins.row.config',
|
|
561
|
+
key: 'dsh-choice#choice',
|
|
562
|
+
}, ChoiceSettings));
|
|
563
|
+
} catch (error) {
|
|
564
|
+
console.warn('dsh-choice: settings form failed', error);
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
};
|
|
568
|
+
},
|
|
569
|
+
});
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Choice">
|
|
2
|
+
<rect x="2" y="2" width="60" height="60" rx="16" fill="#0F1720"/>
|
|
3
|
+
<circle cx="18" cy="20" r="2.6" fill="#93B4FF"/>
|
|
4
|
+
<circle cx="18" cy="32" r="2.6" fill="#93B4FF"/>
|
|
5
|
+
<circle cx="18" cy="44" r="2.6" fill="#93B4FF"/>
|
|
6
|
+
<path d="M25 20h21M25 32h21" stroke="#5B8CFF" stroke-width="3.4" stroke-linecap="round"/>
|
|
7
|
+
<path d="M25 44h12" stroke="#5B8CFF" stroke-width="3.4" stroke-linecap="round" opacity=".45"/>
|
|
8
|
+
<path d="M41 44l4 4 8-9" stroke="#93B4FF" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
9
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { createReadStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join, dirname, extname, isAbsolute } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
|
|
7
|
+
export const name = 'dsh-choice';
|
|
8
|
+
export const inject = ['tools'];
|
|
9
|
+
|
|
10
|
+
/** Language choices stored in settings.json; `auto` follows the application locale. */
|
|
11
|
+
const LANGUAGES = ['ru', 'en', 'zh', 'auto'];
|
|
12
|
+
const DEFAULT_SETTINGS = { language: 'ru' };
|
|
13
|
+
|
|
14
|
+
/** Read a JSON file, returning null instead of throwing on a missing or broken one. */
|
|
15
|
+
function readJson(file) {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Write JSON through a temporary file so a crash never leaves half a file. */
|
|
24
|
+
function writeJson(file, value) {
|
|
25
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
26
|
+
const temporary = `${file}.tmp`;
|
|
27
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
28
|
+
renameSync(temporary, file);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const dataRoot = () => {
|
|
32
|
+
const base = process.env.LOCALAPPDATA
|
|
33
|
+
|| (process.platform === 'darwin' ? join(homedir(), 'Library', 'Application Support') : join(homedir(), '.local', 'share'));
|
|
34
|
+
return join(base, 'dsh-choice');
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const TYPES = {
|
|
38
|
+
'.png': 'image/png',
|
|
39
|
+
'.jpg': 'image/jpeg',
|
|
40
|
+
'.jpeg': 'image/jpeg',
|
|
41
|
+
'.webp': 'image/webp',
|
|
42
|
+
'.gif': 'image/gif',
|
|
43
|
+
'.wav': 'audio/wav',
|
|
44
|
+
'.mp3': 'audio/mpeg',
|
|
45
|
+
'.ogg': 'audio/ogg',
|
|
46
|
+
'.m4a': 'audio/mp4',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A choice board printed as the final message of a turn.
|
|
51
|
+
*
|
|
52
|
+
* The tool renders the card and returns immediately; the user's click posts a
|
|
53
|
+
* normal chat message, which starts the next turn. The card itself never blocks
|
|
54
|
+
* the agent and becomes inert after a pick.
|
|
55
|
+
*/
|
|
56
|
+
export function apply(ctx, config = {}) {
|
|
57
|
+
const root = dataRoot();
|
|
58
|
+
const mediaDir = join(root, 'media');
|
|
59
|
+
mkdirSync(mediaDir, { recursive: true });
|
|
60
|
+
const port = Number.isFinite(config.bridgePort) ? Number(config.bridgePort) : 49236;
|
|
61
|
+
const base = `http://127.0.0.1:${port}`;
|
|
62
|
+
const media = new Map();
|
|
63
|
+
const settingsFile = join(root, 'settings.json');
|
|
64
|
+
|
|
65
|
+
/** The stored settings, falling back to the defaults on any damage. */
|
|
66
|
+
const loadSettings = () => {
|
|
67
|
+
const stored = readJson(settingsFile);
|
|
68
|
+
const language = LANGUAGES.includes(stored?.language) ? stored.language : DEFAULT_SETTINGS.language;
|
|
69
|
+
return { language };
|
|
70
|
+
};
|
|
71
|
+
const saveSettings = (input) => {
|
|
72
|
+
const language = LANGUAGES.includes(input?.language) ? input.language : DEFAULT_SETTINGS.language;
|
|
73
|
+
const next = { language };
|
|
74
|
+
writeJson(settingsFile, next);
|
|
75
|
+
return next;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const attach = (source) => {
|
|
79
|
+
if (typeof source !== 'string' || !source) return null;
|
|
80
|
+
if (/^https?:\/\//i.test(source)) return source;
|
|
81
|
+
if (!isAbsolute(source) || !existsSync(source)) return null;
|
|
82
|
+
const ext = extname(source).toLowerCase();
|
|
83
|
+
if (!TYPES[ext]) return null;
|
|
84
|
+
const size = statSync(source).size;
|
|
85
|
+
if (size > 25 * 1024 * 1024) return null;
|
|
86
|
+
|
|
87
|
+
// The file may be mid-rewrite by the agent (Windows sharing violations);
|
|
88
|
+
// retry briefly instead of returning a half-written snapshot.
|
|
89
|
+
let bytes = null;
|
|
90
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
91
|
+
try {
|
|
92
|
+
bytes = readFileSync(source);
|
|
93
|
+
break;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (attempt === 3) throw new Error(`cannot read ${source}: ${error?.code ?? error}`);
|
|
96
|
+
sleepSync(120);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (bytes === null) return null;
|
|
100
|
+
|
|
101
|
+
// Content-addressed: the same path with new bytes gets a new URL, so a
|
|
102
|
+
// rewritten file never resolves to the previous snapshot.
|
|
103
|
+
const name = `${createHash('sha256').update(bytes).digest('hex').slice(0, 12)}${ext}`;
|
|
104
|
+
const target = join(mediaDir, name);
|
|
105
|
+
if (!existsSync(target) || statSync(target).size !== bytes.length) {
|
|
106
|
+
writeFileSync(target, bytes);
|
|
107
|
+
}
|
|
108
|
+
media.set(name, TYPES[ext]);
|
|
109
|
+
return `${base}/media/${name}`;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const sleepSync = (ms) => {
|
|
113
|
+
const until = Date.now() + ms;
|
|
114
|
+
while (Date.now() < until) {
|
|
115
|
+
// Busy-wait is fine here: this runs once per option during tool setup.
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const tool = {
|
|
120
|
+
name: 'ask_user_choice',
|
|
121
|
+
description:
|
|
122
|
+
'Ask the user to pick exactly one option when the options are images, sounds, or mixed media, '
|
|
123
|
+
+ 'or when the turn should end and the user can answer whenever they want. '
|
|
124
|
+
+ 'For plain-text choices prefer the standard ask_user_question. '
|
|
125
|
+
+ 'Options are text, image or audio. The user presses the pick button on one row; a normal chat message '
|
|
126
|
+
+ 'with that choice is posted and starts the next turn. After that the card stops accepting picks, '
|
|
127
|
+
+ 'but its sounds and image previews stay playable. A trailing "none of these" row is always added - '
|
|
128
|
+
+ 'pass its label as noneLabel. This tool returns immediately and never blocks the turn, unlike '
|
|
129
|
+
+ 'ask_user_question which waits for the answer. Write every visible text (title, subtitle, option titles '
|
|
130
|
+
+ 'and descriptions, noneLabel) in the language you are speaking with the user.',
|
|
131
|
+
parameters: {
|
|
132
|
+
type: 'object',
|
|
133
|
+
additionalProperties: false,
|
|
134
|
+
properties: {
|
|
135
|
+
title: { type: 'string', description: 'Board heading' },
|
|
136
|
+
subtitle: { type: 'string', description: 'Optional one-line hint under the heading' },
|
|
137
|
+
options: {
|
|
138
|
+
type: 'array',
|
|
139
|
+
description: 'Two to eight options',
|
|
140
|
+
items: {
|
|
141
|
+
type: 'object',
|
|
142
|
+
additionalProperties: false,
|
|
143
|
+
properties: {
|
|
144
|
+
id: { type: 'string' },
|
|
145
|
+
kind: { type: 'string', enum: ['text', 'image', 'audio'] },
|
|
146
|
+
title: { type: 'string' },
|
|
147
|
+
description: { type: 'string' },
|
|
148
|
+
value: { type: 'string', description: 'Text sent to the chat for kind=text' },
|
|
149
|
+
src: { type: 'string', description: 'Image or audio: http(s) URL or absolute local path' },
|
|
150
|
+
},
|
|
151
|
+
required: ['kind'],
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
noneLabel: { type: 'string', description: 'Label of the trailing "none of these" row' },
|
|
155
|
+
},
|
|
156
|
+
required: ['options'],
|
|
157
|
+
},
|
|
158
|
+
output: {
|
|
159
|
+
schema: {
|
|
160
|
+
type: 'object',
|
|
161
|
+
additionalProperties: false,
|
|
162
|
+
properties: {
|
|
163
|
+
boardId: { type: 'string' },
|
|
164
|
+
shown: { type: 'boolean' },
|
|
165
|
+
media: {
|
|
166
|
+
type: 'array',
|
|
167
|
+
items: {
|
|
168
|
+
type: 'object',
|
|
169
|
+
additionalProperties: false,
|
|
170
|
+
properties: { index: { type: 'number' }, url: { type: 'string' } },
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
required: ['shown'],
|
|
175
|
+
},
|
|
176
|
+
render: (_args, value) => [{ type: 'text', text: `[media:${JSON.stringify(value.media ?? [])}]` }],
|
|
177
|
+
},
|
|
178
|
+
async execute(args, exec) {
|
|
179
|
+
const depth = exec?.agent?.options?.subagentDepth;
|
|
180
|
+
const isSubagent = exec?.parent !== undefined
|
|
181
|
+
|| exec?.agent?.address !== undefined
|
|
182
|
+
|| (typeof depth === 'number' && depth > 0);
|
|
183
|
+
if (isSubagent) {
|
|
184
|
+
throw new Error('human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent\'s final result');
|
|
185
|
+
}
|
|
186
|
+
const options = Array.isArray(args?.options) ? args.options : [];
|
|
187
|
+
if (options.length < 2) throw new Error('options must contain at least two items');
|
|
188
|
+
if (options.length > 8) throw new Error('at most 8 options per board');
|
|
189
|
+
const media = [];
|
|
190
|
+
options.forEach((option, index) => {
|
|
191
|
+
if (!['text', 'image', 'audio'].includes(option?.kind)) {
|
|
192
|
+
throw new Error(`option ${index + 1}: kind must be text, image or audio`);
|
|
193
|
+
}
|
|
194
|
+
if (option.kind === 'text') return;
|
|
195
|
+
const url = attach(option.src);
|
|
196
|
+
if (!url) throw new Error(`option ${index + 1}: src must be an http(s) URL or an existing image/audio file`);
|
|
197
|
+
media.push({ index, url });
|
|
198
|
+
});
|
|
199
|
+
return { boardId: randomUUID().slice(0, 8), shown: true, media };
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const server = createServer((req, res) => {
|
|
204
|
+
const url = new URL(req.url ?? '/', base);
|
|
205
|
+
const cors = {
|
|
206
|
+
'access-control-allow-origin': '*',
|
|
207
|
+
'access-control-allow-headers': 'content-type',
|
|
208
|
+
'access-control-allow-methods': 'GET,POST,OPTIONS',
|
|
209
|
+
'cache-control': 'no-store',
|
|
210
|
+
};
|
|
211
|
+
const json = (code, body) => {
|
|
212
|
+
res.writeHead(code, { ...cors, 'content-type': 'application/json; charset=utf-8' });
|
|
213
|
+
res.end(JSON.stringify(body));
|
|
214
|
+
};
|
|
215
|
+
if (req.method === 'OPTIONS') {
|
|
216
|
+
res.writeHead(204, cors).end();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (req.method === 'GET' && url.pathname === '/status') {
|
|
220
|
+
json(200, { ok: true, port, media: media.size });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (req.method === 'GET' && url.pathname === '/settings') {
|
|
224
|
+
json(200, { values: loadSettings(), defaults: DEFAULT_SETTINGS, options: LANGUAGES });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (req.method === 'POST' && url.pathname === '/settings') {
|
|
228
|
+
let body = '';
|
|
229
|
+
req.on('data', (chunk) => { body += chunk; if (body.length > 4096) req.destroy(); });
|
|
230
|
+
req.on('end', () => {
|
|
231
|
+
try {
|
|
232
|
+
const parsed = body ? JSON.parse(body) : {};
|
|
233
|
+
json(200, { values: saveSettings(parsed), defaults: DEFAULT_SETTINGS });
|
|
234
|
+
} catch (error) {
|
|
235
|
+
json(400, { ok: false, error: String(error?.message ?? error) });
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (req.method === 'GET' && url.pathname.startsWith('/media/')) {
|
|
241
|
+
const fileName = url.pathname.slice('/media/'.length);
|
|
242
|
+
const type = media.get(fileName) ?? TYPES[extname(fileName).toLowerCase()];
|
|
243
|
+
const file = join(mediaDir, fileName);
|
|
244
|
+
if (!type || !existsSync(file)) {
|
|
245
|
+
json(404, { ok: false, error: 'unknown media' });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
res.writeHead(200, { 'content-type': type, 'content-length': statSync(file).size, 'cache-control': 'no-store' });
|
|
249
|
+
createReadStream(file).pipe(res);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (req.method === 'POST' && url.pathname === '/attach') {
|
|
253
|
+
let body = '';
|
|
254
|
+
req.on('data', (chunk) => { body += chunk; if (body.length > 4096) req.destroy(); });
|
|
255
|
+
req.on('end', () => {
|
|
256
|
+
try {
|
|
257
|
+
const { path } = body ? JSON.parse(body) : {};
|
|
258
|
+
const attached = attach(path);
|
|
259
|
+
if (!attached) {
|
|
260
|
+
json(400, { ok: false, error: 'cannot attach this file' });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
json(200, { ok: true, url: attached });
|
|
264
|
+
} catch (error) {
|
|
265
|
+
json(400, { ok: false, error: String(error?.message ?? error) });
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
json(404, { ok: false, error: 'not found' });
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
ctx.effect(() => {
|
|
274
|
+
const dispose = ctx.tools.register(tool);
|
|
275
|
+
return () => {
|
|
276
|
+
server.close?.();
|
|
277
|
+
dispose?.();
|
|
278
|
+
};
|
|
279
|
+
});
|
|
280
|
+
server.once('error', () => {});
|
|
281
|
+
server.listen(port, '127.0.0.1');
|
|
282
|
+
}
|
package/locale/en.json
ADDED
package/locale/ru.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@machine202502/dsh-choice",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Single-choice board in the chat: pick one of several answers, images or sounds with a click",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "machine202502",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=20"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./index.js",
|
|
13
|
+
"./client": "./client.js",
|
|
14
|
+
"./locale/*.json": "./locale/*.json",
|
|
15
|
+
"./icon": "./icon.svg",
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"index.js",
|
|
20
|
+
"client.js",
|
|
21
|
+
"cordis.patch.yml",
|
|
22
|
+
"icon.svg",
|
|
23
|
+
"locale/*.json",
|
|
24
|
+
"README.md",
|
|
25
|
+
"README.ru.md"
|
|
26
|
+
],
|
|
27
|
+
"dsh": {
|
|
28
|
+
"bundle": {
|
|
29
|
+
"patch": "./cordis.patch.yml"
|
|
30
|
+
},
|
|
31
|
+
"client": {
|
|
32
|
+
"platform": "web",
|
|
33
|
+
"immediately": true,
|
|
34
|
+
"inject": [
|
|
35
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"icon": "./icon.svg",
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public",
|
|
42
|
+
"registry": "https://registry.npmjs.org/"
|
|
43
|
+
}
|
|
44
|
+
}
|