@dungsil/dsh-plugin-ui-model-picker 0.2.3 → 0.2.4

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.
Files changed (3) hide show
  1. package/README.md +4 -4
  2. package/dist/client.js +104 -45
  3. package/package.json +4 -2
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ![모델 선택 팝오버의 라이트 모드와 다크 모드를 꼭짓점 대각선으로 나눈 미리보기](./assets/light-dark-preview.webp)
4
4
 
5
- DSH Web 입력창에서 모델과 추론 강도를 선택합니다. 하나의 선택 버튼을 누르면 추론 강도 팝오버가 먼저 열리며, 팝오버 안의 모델명을 눌러 모델 목록으로 이동합니다.
5
+ DSH Web 입력창에서 모델과 추론 강도를 선택합니다. 하나의 선택 버튼을 누르면 추론 강도 팝오버가 먼저 열리며, 팝오버 안의 모델명을 눌러 모델 목록으로 이동합니다. 모델을 고르면 실제 모델은 바로 바뀌지 않고 추론 강도 슬라이더가 이어서 표시되며, 강도를 설정할 때 모델이 함께 적용됩니다.
6
6
 
7
7
  위 이미지는 플러그인 컴포넌트에 예시 모델 목록과 테마 색상을 제공한 검증 화면을 각각 캡처해 합성했습니다. 표시된 모델명과 추론 단계는 예시이며, 실제 목록은 연결한 제공자가 결정합니다.
8
8
 
@@ -10,14 +10,14 @@ DSH Web 입력창에서 모델과 추론 강도를 선택합니다. 하나의
10
10
 
11
11
  1. DSH 플러그인 설치 화면에서 `@dungsil/dsh-plugin-ui-model-picker`를 설치하고 활성화합니다.
12
12
  2. 입력창 오른쪽 선택 버튼을 눌러 추론 강도 슬라이더를 엽니다. 방향키와 Home/End 키도 사용할 수 있습니다.
13
- 3. 모델을 바꾸려면 팝오버 상단의 모델명 부분을 누르고 제공자별 모델을 선택합니다. 오른쪽 위 재설정 아이콘은 기본 추론 강도를 복원합니다.
13
+ 3. 모델을 바꾸려면 팝오버 상단의 모델명 부분을 누르고 제공자별 모델을 선택합니다. 추론 강도를 지원하는 모델을 고르면 슬라이더가 이어서 나타나며, 강도를 설정해야 모델이 적용됩니다. 오른쪽 위 재설정 아이콘은 기본 추론 강도를 복원합니다.
14
14
 
15
15
  ## 동작과 호환성
16
16
 
17
17
  - DSH `0.2.1-rc.5` 이상 `0.3.0` 미만의 Web UI를 지원합니다. 기본 `ui-model-selection` 기능이 활성화되어 있어야 합니다.
18
18
  - 공용 모델 디렉터리와 선택 API를 그대로 사용하므로 `/model` 명령과 선택 상태를 공유합니다. 별도 모델 설정을 저장하거나 API 키를 다루지 않습니다.
19
- - 실제 제공자 목록과 모델별 추론 메타데이터만 사용합니다. 별도 모델 목록, Fast 모드, 고정 추론 단계는 추가하지 않습니다. 추론을 지원하지 않는 모델에는 슬라이더를 표시하지 않으며, 팝오버 안에서 모델은 계속 바꿀 수 있습니다.
20
- - 다른 모델로 바꾸면 새 모델의 기본 추론 강도를 사용합니다. 기본값 재설정은 별도 강도 지정을 해제합니다.
19
+ - 실제 제공자 목록과 모델별 추론 메타데이터만 사용합니다. 별도 모델 목록, Fast 모드, 고정 추론 단계는 추가하지 않습니다. 추론을 지원하지 않는 모델에는 슬라이더를 표시하지 않으며, 그런 모델은 목록에서 고르는 즉시 적용됩니다.
20
+ - 다른 모델로 바꾸면 그 모델이 지원하는 추론 강도에서 슬라이더가 시작되고, 강도를 설정해야 모델 선택이 적용됩니다. 슬라이더를 건드리지 않고 팝오버를 닫으면 모델은 바뀌지 않습니다. 기본값 재설정은 별도 강도 지정을 해제합니다.
21
21
  - Escape, 바깥 클릭, 스크롤, 창 크기 변경으로 메뉴를 닫습니다. 메뉴의 방향키와 Tab 탐색을 지원합니다. 한국어·영어·중국어(간체)와 DSH 테마 색상을 따릅니다.
22
22
  - 기본 모델 슬롯만 우선순위로 대체합니다. 제거하면 기본 선택 UI가 돌아오며 모델 선택은 유지됩니다. 같은 슬롯을 대체하는 다른 플러그인과는 함께 사용하지 마세요.
23
23
 
package/dist/client.js CHANGED
@@ -108,6 +108,7 @@ const styles = `${composerStyles}
108
108
  .dsh-codex-slider input::-moz-range-track { height: 28px; background: transparent; }
109
109
  .dsh-codex-slider input::-moz-range-thumb { width: 26px; height: 26px; border: 1px solid #0000000a; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px #0002; }
110
110
  .dsh-codex-notice { padding: 6px 10px; margin: 0; font-size: 12px; overflow-wrap: anywhere; }
111
+ .dsh-codex-status { position: absolute; width: 1px; height: 1px; padding: 0; margin: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
111
112
  .dsh-codex-popover [role=alert] { color: var(--dsw-alias-state-error-primary, #b54424); }
112
113
  .dsh-codex-popover :focus-visible { outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: -2px; }
113
114
  .dsh-codex-popover :disabled { color: var(--dsw-alias-label-dimmed); cursor: default; }
@@ -127,7 +128,6 @@ const copy = {
127
128
  model: "모델 선택",
128
129
  effort: "추론 강도",
129
130
  choose: "추론 수준 선택",
130
- default: "제공자 기본값",
131
131
  reset: "기본값으로 재설정",
132
132
  loading: "모델을 불러오는 중입니다…",
133
133
  empty: "사용할 수 있는 모델이 없습니다.",
@@ -141,7 +141,6 @@ const copy = {
141
141
  model: "Select model",
142
142
  effort: "Reasoning effort",
143
143
  choose: "Select reasoning effort",
144
- default: "Provider default",
145
144
  reset: "Reset to default",
146
145
  loading: "Loading models…",
147
146
  empty: "No models available.",
@@ -155,7 +154,6 @@ const copy = {
155
154
  model: "选择模型",
156
155
  effort: "推理强度",
157
156
  choose: "选择推理级别",
158
- default: "提供商默认值",
159
157
  reset: "重置为默认值",
160
158
  loading: "正在加载模型…",
161
159
  empty: "没有可用的模型。",
@@ -174,25 +172,28 @@ function apply(ctx) {
174
172
  const { active: locale } = useLocaleSnapshot(ctx.locale);
175
173
  const text = copy[locale.startsWith("ko") ? "ko" : locale.startsWith("zh") ? "zh" : "en"];
176
174
  const [requestedPanel, setPanel] = (0, react.useState)(null);
175
+ const [staged, setStaged] = (0, react.useState)(null);
177
176
  const [draft, setDraft] = (0, react.useState)(0);
178
177
  const [failed, setFailed] = (0, react.useState)(false);
179
178
  const trigger = (0, react.useRef)(null);
180
179
  const anchor = (0, react.useRef)(null);
181
180
  const popup = (0, react.useRef)(null);
182
181
  const pending = (0, react.useRef)(false);
182
+ const sliderSelection = (0, react.useRef)(null);
183
183
  const mounted = (0, react.useRef)(true);
184
184
  const id = (0, react.useId)();
185
185
  const current = state.current;
186
186
  const model = state.groups.find((item) => item.id === current?.provider)?.models.find((item) => item.id === current?.model);
187
187
  const reasoning = model?.reasoning;
188
- const efforts = reasoning?.efforts.length ? [...reasoning.defaultEffort === void 0 ? [{
189
- id: void 0,
190
- name: text.default
191
- }] : [], ...reasoning.efforts] : [];
192
- const panel = requestedPanel === "effort" && !efforts.length ? "model" : requestedPanel;
188
+ const efforts = reasoning?.efforts ?? [];
189
+ const stagedGroup = staged ? state.groups.find((item) => item.id === staged.provider) : void 0;
190
+ const stagedModel = staged ? stagedGroup?.models.find((item) => item.id === staged.model) : model;
191
+ const stagedEfforts = (stagedModel?.reasoning)?.efforts ?? [];
192
+ const panel = requestedPanel === "effort" && !stagedEfforts.length ? "model" : requestedPanel;
193
193
  const effective = current?.reasoningEffort ?? reasoning?.defaultEffort;
194
194
  const effortIndex = efforts.findIndex((item) => item.id === effective);
195
195
  const effortLabel = efforts.length ? efforts[effortIndex]?.name ?? state.retainedEffort : void 0;
196
+ const stagedName = stagedModel?.name ?? (staged ? staged.model : current?.model) ?? text.model;
196
197
  const busy = state.status === "selecting";
197
198
  const disabled = locked || busy;
198
199
  (0, react.useEffect)(() => {
@@ -203,10 +204,16 @@ function apply(ctx) {
203
204
  };
204
205
  }, [directory]);
205
206
  const close = () => {
207
+ sliderSelection.current = null;
206
208
  setPanel(null);
209
+ setStaged(null);
207
210
  if (trigger.current) (0, _deepseek_ai_dsh_client_ui_primitives.focusWithoutRing)(trigger.current);
208
211
  };
209
- (0, _deepseek_ai_dsh_client_ui_primitives.useDismissOnOutsidePointer)(anchor, panel !== null, () => setPanel(null), popup);
212
+ (0, _deepseek_ai_dsh_client_ui_primitives.useDismissOnOutsidePointer)(anchor, panel !== null, () => {
213
+ sliderSelection.current = null;
214
+ setPanel(null);
215
+ setStaged(null);
216
+ }, popup);
210
217
  (0, react.useLayoutEffect)(() => {
211
218
  if (!panel || !popup.current || !anchor.current) return;
212
219
  const element = popup.current;
@@ -252,25 +259,42 @@ function apply(ctx) {
252
259
  };
253
260
  }, [panel]);
254
261
  (0, react.useEffect)(() => {
255
- if (locked) setPanel(null);
262
+ if (locked) {
263
+ sliderSelection.current = null;
264
+ setPanel(null);
265
+ setStaged(null);
266
+ }
256
267
  }, [locked]);
257
268
  (0, react.useEffect)(() => {
269
+ const selection = sliderSelection.current;
270
+ sliderSelection.current = null;
271
+ if (selection && current?.provider === selection.provider && current?.model === selection.model && current?.reasoningEffort === selection.reasoningEffort) {
272
+ setStaged(null);
273
+ return;
274
+ }
258
275
  setPanel(null);
276
+ setStaged(null);
259
277
  }, [
260
278
  current?.provider,
261
279
  current?.model,
262
280
  current?.reasoningEffort
263
281
  ]);
264
- const select = async (selection) => {
282
+ const select = async (selection, keepSlider = false) => {
265
283
  if (disabled || pending.current) return;
266
284
  pending.current = true;
285
+ sliderSelection.current = keepSlider ? selection : null;
267
286
  setFailed(false);
268
287
  try {
269
288
  const result = await directory.select(selection);
270
289
  if (!mounted.current) return;
271
- if (result.ok) close();
272
- else setFailed(true);
290
+ if (result.ok) {
291
+ if (!keepSlider) close();
292
+ } else {
293
+ sliderSelection.current = null;
294
+ setFailed(true);
295
+ }
273
296
  } catch {
297
+ sliderSelection.current = null;
274
298
  if (mounted.current) setFailed(true);
275
299
  } finally {
276
300
  pending.current = false;
@@ -282,19 +306,50 @@ function apply(ctx) {
282
306
  close();
283
307
  return;
284
308
  }
309
+ setStaged(null);
285
310
  setDraft(Math.max(0, effortIndex));
286
311
  setFailed(false);
287
312
  setPanel("effort");
288
313
  directory.load().catch(() => {});
289
314
  };
290
315
  const selectEffort = (index) => {
291
- if (!current || !efforts[index]) return;
292
- const effort = efforts[index].id;
293
- select({
316
+ const target = staged ?? (current ? {
294
317
  provider: current.provider,
295
- model: current.model,
296
- ...effort === void 0 ? {} : { reasoningEffort: effort }
318
+ model: current.model
319
+ } : null);
320
+ const effort = stagedEfforts[index];
321
+ if (!target || !effort) return;
322
+ select({
323
+ provider: target.provider,
324
+ model: target.model,
325
+ reasoningEffort: effort.id
326
+ }, true);
327
+ };
328
+ const stageModel = (provider, option) => {
329
+ const same = provider === current?.provider && option.id === current?.model;
330
+ const optionReasoning = option.reasoning;
331
+ const optionEfforts = optionReasoning?.efforts ?? [];
332
+ if (optionEfforts.length < 2) {
333
+ if (same) {
334
+ close();
335
+ return;
336
+ }
337
+ const effort = optionReasoning?.defaultEffort;
338
+ select({
339
+ provider,
340
+ model: option.id,
341
+ ...effort === void 0 ? {} : { reasoningEffort: effort }
342
+ });
343
+ return;
344
+ }
345
+ const preferredEffort = same ? current?.reasoningEffort ?? optionReasoning?.defaultEffort : optionReasoning?.defaultEffort ?? current?.reasoningEffort;
346
+ setStaged({
347
+ provider,
348
+ model: option.id
297
349
  });
350
+ setDraft(Math.max(0, optionEfforts.findIndex((item) => item.id === preferredEffort)));
351
+ setFailed(false);
352
+ setPanel("effort");
298
353
  };
299
354
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
300
355
  className: "dsh-codex-picker",
@@ -305,13 +360,16 @@ function apply(ctx) {
305
360
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconDataOutlineRegular, { size: 16 }),
306
361
  label: model?.name ?? current?.model ?? text.model,
307
362
  detail: effortLabel,
308
- disabled,
363
+ disabled: locked,
364
+ "aria-disabled": disabled,
309
365
  "aria-label": `${efforts.length ? text.choose : text.model}: ${model?.name ?? current?.model ?? text.model}${effortLabel ? `, ${effortLabel}` : ""}`,
310
366
  title: `${model?.name ?? current?.model ?? text.model}${effortLabel ? ` ${effortLabel}` : ""}`,
311
367
  "aria-haspopup": "dialog",
312
368
  "aria-expanded": panel !== null,
313
369
  "aria-controls": panel ? id : void 0,
314
- onClick: (event) => show(event.currentTarget)
370
+ onClick: (event) => {
371
+ if (!disabled) show(event.currentTarget);
372
+ }
315
373
  })
316
374
  }), panel && (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.MenuSurface, {
317
375
  id,
@@ -320,7 +378,7 @@ function apply(ctx) {
320
378
  "aria-label": panel === "model" ? text.model : text.effort,
321
379
  className: "dsh-codex-popover",
322
380
  children: [
323
- panel === "model" ? efforts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
381
+ panel === "model" ? stagedEfforts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
324
382
  type: "button",
325
383
  className: "dsh-codex-back",
326
384
  onClick: () => setPanel("effort"),
@@ -334,25 +392,30 @@ function apply(ctx) {
334
392
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
335
393
  type: "button",
336
394
  className: "dsh-codex-model",
337
- disabled,
338
- "aria-label": `${text.model}: ${model?.name ?? current?.model ?? text.model}`,
339
- onClick: () => setPanel("model"),
340
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: efforts[draft]?.name ?? effortLabel ?? text.default }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
341
- model?.name ?? current?.model ?? text.model,
395
+ disabled: locked,
396
+ "aria-disabled": disabled,
397
+ "aria-label": `${text.model}: ${stagedName}`,
398
+ onClick: () => {
399
+ if (!disabled) setPanel("model");
400
+ },
401
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: stagedEfforts[draft]?.name }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
402
+ stagedName,
342
403
  " ",
343
404
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronRightOutlineRegular, { size: 14 })
344
405
  ] })]
345
406
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
346
407
  type: "button",
347
408
  className: "dsh-codex-reset",
348
- disabled: disabled || !current,
409
+ disabled: locked || !(staged ?? current),
410
+ "aria-disabled": disabled || !(staged ?? current),
349
411
  "aria-label": text.reset,
350
412
  title: text.reset,
351
413
  onClick: () => {
352
- if (current) select({
414
+ const target = staged ?? (current ? {
353
415
  provider: current.provider,
354
416
  model: current.model
355
- });
417
+ } : null);
418
+ if (target) select(target);
356
419
  },
357
420
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconResetOutlineRegular, {})
358
421
  })]
@@ -363,7 +426,7 @@ function apply(ctx) {
363
426
  children: text.loading
364
427
  }),
365
428
  busy && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
366
- className: "dsh-codex-notice",
429
+ className: panel === "effort" ? "dsh-codex-status" : "dsh-codex-notice",
367
430
  role: "status",
368
431
  children: text.busy
369
432
  }),
@@ -397,14 +460,7 @@ function apply(ctx) {
397
460
  "aria-checked": provider.id === current?.provider && option.id === current?.model,
398
461
  className: "dsh-codex-option",
399
462
  disabled,
400
- onClick: () => {
401
- const effort = provider.id === current?.provider && option.id === current?.model ? current.reasoningEffort : option.reasoning?.defaultEffort;
402
- select({
403
- provider: provider.id,
404
- model: option.id,
405
- ...effort === void 0 ? {} : { reasoningEffort: effort }
406
- });
407
- },
463
+ onClick: () => stageModel(provider.id, option),
408
464
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [option.name, option.description && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("small", { children: option.description })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
409
465
  className: "dsh-codex-check",
410
466
  "aria-hidden": "true",
@@ -428,26 +484,29 @@ function apply(ctx) {
428
484
  },
429
485
  children: text.retry
430
486
  })
431
- ] }) : efforts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
487
+ ] }) : stagedEfforts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
432
488
  className: "dsh-codex-slider",
433
489
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
434
490
  className: "dsh-codex-rail",
435
- style: { "--dsh-codex-progress": `${efforts.length > 1 ? draft / (efforts.length - 1) * 100 : 0}%` },
491
+ style: { "--dsh-codex-progress": `${stagedEfforts.length > 1 ? draft / (stagedEfforts.length - 1) * 100 : 0}%` },
436
492
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
437
493
  className: "dsh-codex-track",
438
494
  "aria-hidden": "true",
439
- children: efforts.map((effort, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { "data-filled": index <= draft }, effort.id ?? "default"))
495
+ children: stagedEfforts.map((effort, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { "data-filled": index <= draft }, effort.id))
440
496
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
441
497
  id: `${id}-effort`,
442
498
  type: "range",
443
499
  min: 0,
444
- max: efforts.length - 1,
500
+ max: stagedEfforts.length - 1,
445
501
  step: 1,
446
502
  value: draft,
447
- disabled: disabled || efforts.length < 2,
503
+ disabled: locked || stagedEfforts.length < 2,
504
+ "aria-disabled": disabled || stagedEfforts.length < 2,
448
505
  "aria-label": text.effort,
449
- "aria-valuetext": efforts[draft]?.name,
450
- onChange: (event) => setDraft(Number(event.target.value)),
506
+ "aria-valuetext": stagedEfforts[draft]?.name,
507
+ onChange: (event) => {
508
+ if (!disabled) setDraft(Number(event.target.value));
509
+ },
451
510
  onPointerUp: (event) => selectEffort(Number(event.currentTarget.value)),
452
511
  onKeyUp: (event) => {
453
512
  if ([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dungsil/dsh-plugin-ui-model-picker",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Model and reasoning effort picker for the DeepSeek Harness composer.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,6 +40,7 @@
40
40
  "@dungsil/dsh-ui": "0.0.0",
41
41
  "@types/react": "18.3.31",
42
42
  "@types/react-dom": "18.3.7",
43
+ "happy-dom": "21.0.4",
43
44
  "react": "18.3.1",
44
45
  "react-dom": "18.3.1",
45
46
  "tsdown": "0.23.4"
@@ -88,6 +89,7 @@
88
89
  },
89
90
  "scripts": {
90
91
  "build": "tsdown",
91
- "typecheck": "tsc --noEmit"
92
+ "typecheck": "tsc --noEmit",
93
+ "test": "node --test test/*.test.mjs"
92
94
  }
93
95
  }