@dungsil/dsh-plugin-ui-model-picker 0.2.2 → 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 +108 -48
  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
@@ -36,7 +36,7 @@ const composerStyles = `
36
36
  .dsh-plugin-composer-chevron, .dsh-plugin-composer-detail { color: var(--dsw-alias-label-caption); }
37
37
  .dsh-plugin-composer-trigger[aria-expanded=true] .dsh-plugin-composer-chevron { transform: rotate(180deg); }
38
38
  `;
39
- const ComposerTrigger = (0, react.forwardRef)(function ComposerTrigger({ className = "", icon, label, detail, ...props }, ref) {
39
+ const ComposerTrigger = /* @__PURE__ */ (0, react.forwardRef)(function ComposerTrigger({ className = "", icon, label, detail, ...props }, ref) {
40
40
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
41
41
  ...props,
42
42
  ref,
@@ -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: "没有可用的模型。",
@@ -173,25 +171,29 @@ function apply(ctx) {
173
171
  const state = (0, react.useSyncExternalStore)(subscribe, getSnapshot);
174
172
  const { active: locale } = useLocaleSnapshot(ctx.locale);
175
173
  const text = copy[locale.startsWith("ko") ? "ko" : locale.startsWith("zh") ? "zh" : "en"];
176
- const [panel, setPanel] = (0, react.useState)(null);
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] : [];
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;
192
193
  const effective = current?.reasoningEffort ?? reasoning?.defaultEffort;
193
194
  const effortIndex = efforts.findIndex((item) => item.id === effective);
194
- const effortLabel = efforts[effortIndex]?.name ?? state.retainedEffort;
195
+ const effortLabel = efforts.length ? efforts[effortIndex]?.name ?? state.retainedEffort : void 0;
196
+ const stagedName = stagedModel?.name ?? (staged ? staged.model : current?.model) ?? text.model;
195
197
  const busy = state.status === "selecting";
196
198
  const disabled = locked || busy;
197
199
  (0, react.useEffect)(() => {
@@ -202,10 +204,16 @@ function apply(ctx) {
202
204
  };
203
205
  }, [directory]);
204
206
  const close = () => {
207
+ sliderSelection.current = null;
205
208
  setPanel(null);
209
+ setStaged(null);
206
210
  if (trigger.current) (0, _deepseek_ai_dsh_client_ui_primitives.focusWithoutRing)(trigger.current);
207
211
  };
208
- (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);
209
217
  (0, react.useLayoutEffect)(() => {
210
218
  if (!panel || !popup.current || !anchor.current) return;
211
219
  const element = popup.current;
@@ -251,25 +259,42 @@ function apply(ctx) {
251
259
  };
252
260
  }, [panel]);
253
261
  (0, react.useEffect)(() => {
254
- if (locked) setPanel(null);
262
+ if (locked) {
263
+ sliderSelection.current = null;
264
+ setPanel(null);
265
+ setStaged(null);
266
+ }
255
267
  }, [locked]);
256
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
+ }
257
275
  setPanel(null);
276
+ setStaged(null);
258
277
  }, [
259
278
  current?.provider,
260
279
  current?.model,
261
280
  current?.reasoningEffort
262
281
  ]);
263
- const select = async (selection) => {
282
+ const select = async (selection, keepSlider = false) => {
264
283
  if (disabled || pending.current) return;
265
284
  pending.current = true;
285
+ sliderSelection.current = keepSlider ? selection : null;
266
286
  setFailed(false);
267
287
  try {
268
288
  const result = await directory.select(selection);
269
289
  if (!mounted.current) return;
270
- if (result.ok) close();
271
- else setFailed(true);
290
+ if (result.ok) {
291
+ if (!keepSlider) close();
292
+ } else {
293
+ sliderSelection.current = null;
294
+ setFailed(true);
295
+ }
272
296
  } catch {
297
+ sliderSelection.current = null;
273
298
  if (mounted.current) setFailed(true);
274
299
  } finally {
275
300
  pending.current = false;
@@ -281,19 +306,50 @@ function apply(ctx) {
281
306
  close();
282
307
  return;
283
308
  }
309
+ setStaged(null);
284
310
  setDraft(Math.max(0, effortIndex));
285
311
  setFailed(false);
286
312
  setPanel("effort");
287
313
  directory.load().catch(() => {});
288
314
  };
289
315
  const selectEffort = (index) => {
290
- if (!current || !efforts[index]) return;
291
- const effort = efforts[index].id;
292
- select({
316
+ const target = staged ?? (current ? {
293
317
  provider: current.provider,
294
- model: current.model,
295
- ...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
296
349
  });
350
+ setDraft(Math.max(0, optionEfforts.findIndex((item) => item.id === preferredEffort)));
351
+ setFailed(false);
352
+ setPanel("effort");
297
353
  };
298
354
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
299
355
  className: "dsh-codex-picker",
@@ -304,13 +360,16 @@ function apply(ctx) {
304
360
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconDataOutlineRegular, { size: 16 }),
305
361
  label: model?.name ?? current?.model ?? text.model,
306
362
  detail: effortLabel,
307
- disabled,
308
- "aria-label": `${text.choose}: ${model?.name ?? current?.model ?? text.model}${effortLabel ? `, ${effortLabel}` : ""}`,
363
+ disabled: locked,
364
+ "aria-disabled": disabled,
365
+ "aria-label": `${efforts.length ? text.choose : text.model}: ${model?.name ?? current?.model ?? text.model}${effortLabel ? `, ${effortLabel}` : ""}`,
309
366
  title: `${model?.name ?? current?.model ?? text.model}${effortLabel ? ` ${effortLabel}` : ""}`,
310
367
  "aria-haspopup": "dialog",
311
368
  "aria-expanded": panel !== null,
312
369
  "aria-controls": panel ? id : void 0,
313
- onClick: (event) => show(event.currentTarget)
370
+ onClick: (event) => {
371
+ if (!disabled) show(event.currentTarget);
372
+ }
314
373
  })
315
374
  }), panel && (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.MenuSurface, {
316
375
  id,
@@ -319,7 +378,7 @@ function apply(ctx) {
319
378
  "aria-label": panel === "model" ? text.model : text.effort,
320
379
  className: "dsh-codex-popover",
321
380
  children: [
322
- panel === "model" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
381
+ panel === "model" ? stagedEfforts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
323
382
  type: "button",
324
383
  className: "dsh-codex-back",
325
384
  onClick: () => setPanel("effort"),
@@ -333,25 +392,30 @@ function apply(ctx) {
333
392
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
334
393
  type: "button",
335
394
  className: "dsh-codex-model",
336
- disabled,
337
- "aria-label": `${text.model}: ${model?.name ?? current?.model ?? text.model}`,
338
- onClick: () => setPanel("model"),
339
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: efforts[draft]?.name ?? effortLabel ?? text.default }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
340
- 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,
341
403
  " ",
342
404
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronRightOutlineRegular, { size: 14 })
343
405
  ] })]
344
406
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
345
407
  type: "button",
346
408
  className: "dsh-codex-reset",
347
- disabled: disabled || !current,
409
+ disabled: locked || !(staged ?? current),
410
+ "aria-disabled": disabled || !(staged ?? current),
348
411
  "aria-label": text.reset,
349
412
  title: text.reset,
350
413
  onClick: () => {
351
- if (current) select({
414
+ const target = staged ?? (current ? {
352
415
  provider: current.provider,
353
416
  model: current.model
354
- });
417
+ } : null);
418
+ if (target) select(target);
355
419
  },
356
420
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconResetOutlineRegular, {})
357
421
  })]
@@ -362,7 +426,7 @@ function apply(ctx) {
362
426
  children: text.loading
363
427
  }),
364
428
  busy && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
365
- className: "dsh-codex-notice",
429
+ className: panel === "effort" ? "dsh-codex-status" : "dsh-codex-notice",
366
430
  role: "status",
367
431
  children: text.busy
368
432
  }),
@@ -396,14 +460,7 @@ function apply(ctx) {
396
460
  "aria-checked": provider.id === current?.provider && option.id === current?.model,
397
461
  className: "dsh-codex-option",
398
462
  disabled,
399
- onClick: () => {
400
- const effort = provider.id === current?.provider && option.id === current?.model ? current.reasoningEffort : option.reasoning?.defaultEffort;
401
- select({
402
- provider: provider.id,
403
- model: option.id,
404
- ...effort === void 0 ? {} : { reasoningEffort: effort }
405
- });
406
- },
463
+ onClick: () => stageModel(provider.id, option),
407
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", {
408
465
  className: "dsh-codex-check",
409
466
  "aria-hidden": "true",
@@ -427,26 +484,29 @@ function apply(ctx) {
427
484
  },
428
485
  children: text.retry
429
486
  })
430
- ] }) : efforts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
487
+ ] }) : stagedEfforts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
431
488
  className: "dsh-codex-slider",
432
489
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
433
490
  className: "dsh-codex-rail",
434
- 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}%` },
435
492
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
436
493
  className: "dsh-codex-track",
437
494
  "aria-hidden": "true",
438
- 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))
439
496
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
440
497
  id: `${id}-effort`,
441
498
  type: "range",
442
499
  min: 0,
443
- max: efforts.length - 1,
500
+ max: stagedEfforts.length - 1,
444
501
  step: 1,
445
502
  value: draft,
446
- disabled: disabled || efforts.length < 2,
503
+ disabled: locked || stagedEfforts.length < 2,
504
+ "aria-disabled": disabled || stagedEfforts.length < 2,
447
505
  "aria-label": text.effort,
448
- "aria-valuetext": efforts[draft]?.name,
449
- 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
+ },
450
510
  onPointerUp: (event) => selectEffort(Number(event.currentTarget.value)),
451
511
  onKeyUp: (event) => {
452
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.2",
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
  }