@machine202502/dsh-choice 0.0.0-stage → 0.1.1

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 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
- # Temporary Holding Version
1
+ # dsh-choice
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ ![Plugin settings](docs/img/settings.jpg)
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
+ ![Настройки плагина](docs/img/settings.jpg)
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
+ });
@@ -0,0 +1,5 @@
1
+ - insert:
2
+ - id: choice
3
+ name: '@machine202502/dsh-choice'
4
+ config:
5
+ bridgePort: 49236
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
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Choice",
4
+ "description": "Pick one of several answers, images or sounds right in the chat"
5
+ }
6
+ }
package/locale/ru.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Choice",
4
+ "description": "Выберите один из вариантов, картинок или звуков прямо в чате"
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Choice",
4
+ "description": "在聊天中从多个答案、图片或声音里选择一个"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@machine202502/dsh-choice",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
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
+ }