@fonlan/dsh-task-kanban 0.1.1 → 0.2.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.
@@ -1,8 +1,8 @@
1
- import { useEffect, useRef, useState } from 'react'
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
2
  import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
3
- import { api } from './api'
3
+ import { api, type PresetOption, type ReasoningOptions } from './api'
4
4
  import { toggleBoard, useBoardOpen } from './kanban-state'
5
- import type { KanbanSettingsShape } from '../shared/card'
5
+ import type { KanbanSettingsShape, ModelOption } from '../shared/card'
6
6
 
7
7
  interface FooterActionProps {
8
8
  wide?: boolean
@@ -140,17 +140,141 @@ export function KanbanFooterButton({ wide, t }: FooterActionProps): JSX.Element
140
140
  )
141
141
  }
142
142
 
143
- interface SettingsSectionProps {
143
+ interface SettingsCardProps {
144
144
  t?: (key: string) => string
145
145
  }
146
146
 
147
- /** Plugin settings section: global parallel workers + default model. */
148
- export function KanbanSettingsSection({ t }: SettingsSectionProps): JSX.Element {
147
+ /** Fallback shape while settings load (server always resolves the full set). */
148
+ const EMPTY_SETTINGS: KanbanSettingsShape = {
149
+ maxParallelWorkers: 1,
150
+ refinementModel: '',
151
+ refinementProvider: '',
152
+ refinementReasoningEffort: '',
153
+ refinementPreset: '',
154
+ phaseModel: '',
155
+ phaseProvider: '',
156
+ phaseReasoningEffort: '',
157
+ phasePreset: '',
158
+ }
159
+
160
+ /** The setting field names of one session-type group. */
161
+ const GROUP_FIELDS = {
162
+ refinement: {
163
+ model: 'refinementModel',
164
+ provider: 'refinementProvider',
165
+ effort: 'refinementReasoningEffort',
166
+ preset: 'refinementPreset',
167
+ },
168
+ phase: {
169
+ model: 'phaseModel',
170
+ provider: 'phaseProvider',
171
+ effort: 'phaseReasoningEffort',
172
+ preset: 'phasePreset',
173
+ },
174
+ } as const
175
+
176
+ type SessionGroupKey = keyof typeof GROUP_FIELDS
177
+
178
+ interface SessionDefaultsGroupProps {
179
+ kind: SessionGroupKey
180
+ settings: KanbanSettingsShape
181
+ models: ModelOption[]
182
+ presets: PresetOption[]
183
+ /** Reasoning efforts already fetched, keyed `provider/model`. */
184
+ efforts: Record<string, ReasoningOptions>
185
+ tr: (key: string) => string
186
+ onCommit: (patch: Partial<KanbanSettingsShape>) => Promise<void>
187
+ loadEfforts: (provider: string, model: string) => void
188
+ }
189
+
190
+ /** Model / reasoning effort / agent preset defaults for one session type. */
191
+ function SessionDefaultsGroup({ kind, settings, models, presets, efforts, tr, onCommit, loadEfforts }: SessionDefaultsGroupProps): JSX.Element {
192
+ const fields = GROUP_FIELDS[kind]
193
+ const model = settings[fields.model]
194
+ const provider = settings[fields.provider]
195
+ const effort = settings[fields.effort]
196
+ const preset = settings[fields.preset]
197
+ const effortKey = model !== '' && provider !== '' ? `${provider}/${model}` : ''
198
+ const effortOptions = effortKey !== '' ? (efforts[effortKey]?.efforts ?? []) : []
199
+
200
+ const handleModel = (id: string): void => {
201
+ const found = models.find((m) => m.id === id)
202
+ const nextProvider = found?.provider ?? ''
203
+ void onCommit({ [fields.model]: id, [fields.provider]: nextProvider } as Partial<KanbanSettingsShape>)
204
+ if (id !== '' && nextProvider !== '') loadEfforts(nextProvider, id)
205
+ }
206
+
207
+ return (
208
+ <div className="kb-settings-group">
209
+ <div className="kb-settings-group-title">{tr(kind === 'refinement' ? 'settingsRefineGroup' : 'settingsPhaseGroup')}</div>
210
+ <label className="kb-field kb-field-row">
211
+ <span>{tr('model')}</span>
212
+ <select className="kb-input" value={model} onChange={(e) => handleModel(e.target.value)}>
213
+ <option value="">{tr('defaultOption')}</option>
214
+ {models.map((m) => (
215
+ <option key={m.provider + '/' + m.id} value={m.id}>{m.name ?? m.id}</option>
216
+ ))}
217
+ </select>
218
+ </label>
219
+ <label className="kb-field kb-field-row">
220
+ <span>{tr('reasoningEffort')}</span>
221
+ <select
222
+ className="kb-input"
223
+ value={effort}
224
+ disabled={model === ''}
225
+ onChange={(e) => void onCommit({ [fields.effort]: e.target.value } as Partial<KanbanSettingsShape>)}
226
+ >
227
+ <option value="">{tr('defaultOption')}</option>
228
+ {effortOptions.map((o) => (
229
+ <option key={o.id} value={o.id}>{o.name}</option>
230
+ ))}
231
+ </select>
232
+ </label>
233
+ <label className="kb-field kb-field-row">
234
+ <span>{tr('agentPreset')}</span>
235
+ <select
236
+ className="kb-input"
237
+ value={preset}
238
+ onChange={(e) => void onCommit({ [fields.preset]: e.target.value } as Partial<KanbanSettingsShape>)}
239
+ >
240
+ <option value="">{tr('defaultOption')}</option>
241
+ {presets.map((p) => (
242
+ <option key={p.id} value={p.id}>{p.name ?? p.id}</option>
243
+ ))}
244
+ </select>
245
+ </label>
246
+ </div>
247
+ )
248
+ }
249
+
250
+ /** Plugin settings card (设置 → 插件配置): workers + default model + per-session-type defaults. */
251
+ export function KanbanSettingsCard({ t }: SettingsCardProps): JSX.Element {
149
252
  const tr = t ?? ((key: string) => key)
150
- const [settings, setSettings] = useState<KanbanSettingsShape>({ maxParallelWorkers: 1, defaultModel: '' })
151
- const [models, setModels] = useState<Array<{ provider: string; id: string; name?: string }>>([])
253
+ // Card-local disclosure: collapsed by default, like the built-in plugin cards.
254
+ const [open, setOpen] = useState(false)
255
+ const [settings, setSettings] = useState<KanbanSettingsShape>(EMPTY_SETTINGS)
256
+ const [models, setModels] = useState<ModelOption[]>([])
257
+ const [presets, setPresets] = useState<PresetOption[]>([])
258
+ const [efforts, setEfforts] = useState<Record<string, ReasoningOptions>>({})
152
259
  const [saved, setSaved] = useState(false)
153
260
 
261
+ const commit = useCallback(async (patch: Partial<KanbanSettingsShape>): Promise<void> => {
262
+ try {
263
+ const next = await api.settingsSet(patch)
264
+ setSettings(next)
265
+ setSaved(true)
266
+ window.setTimeout(() => setSaved(false), 1500)
267
+ } catch {
268
+ // keep the previous value
269
+ }
270
+ }, [])
271
+
272
+ // Refs so async callbacks (effort fetch) read the freshest settings/commit.
273
+ const settingsRef = useRef(settings)
274
+ settingsRef.current = settings
275
+ const commitRef = useRef(commit)
276
+ commitRef.current = commit
277
+
154
278
  useEffect(() => {
155
279
  let alive = true
156
280
  api.settingsGet()
@@ -159,51 +283,123 @@ export function KanbanSettingsSection({ t }: SettingsSectionProps): JSX.Element
159
283
  api.models()
160
284
  .then((list) => { if (alive) setModels(list) })
161
285
  .catch(() => undefined)
286
+ api.presets()
287
+ .then((list) => { if (alive) setPresets(list) })
288
+ .catch(() => undefined)
162
289
  return () => { alive = false }
163
290
  }, [])
164
291
 
165
- const commit = async (patch: Partial<KanbanSettingsShape>): Promise<void> => {
166
- try {
167
- const next = await api.settingsSet(patch)
168
- setSettings(next)
169
- setSaved(true)
170
- window.setTimeout(() => setSaved(false), 1500)
171
- } catch {
172
- // keep the previous value
292
+ /** Reasoning routes already requested (dedupe across mount + model changes). */
293
+ const effortFetched = useRef<Set<string>>(new Set())
294
+
295
+ /**
296
+ * Fetch the reasoning efforts of one model route (once per route). A failed
297
+ * fetch is marked `failed` so the stored effort is preserved (see the reset
298
+ * effect below) instead of being wiped by a transient error.
299
+ */
300
+ const loadEfforts = useCallback((provider: string, model: string): void => {
301
+ const key = `${provider}/${model}`
302
+ if (effortFetched.current.has(key)) return
303
+ effortFetched.current.add(key)
304
+ api.reasoningOptions(provider, model)
305
+ .then((opts) => setEfforts((prev) => ({ ...prev, [key]: opts })))
306
+ .catch(() => setEfforts((prev) => ({ ...prev, [key]: { efforts: [], failed: true } })))
307
+ }, [])
308
+
309
+ // Fetch efforts for the models already configured in settings, so the
310
+ // effort selects show their levels (and validate the stored effort) as soon
311
+ // as settings load — the fetch previously ran only on model change.
312
+ useEffect(() => {
313
+ for (const kind of ['refinement', 'phase'] as const) {
314
+ const fields = GROUP_FIELDS[kind]
315
+ const model = settings[fields.model]
316
+ const provider = settings[fields.provider]
317
+ if (model !== '' && provider !== '') loadEfforts(provider, model)
173
318
  }
174
- }
319
+ }, [settings.refinementModel, settings.refinementProvider, settings.phaseModel, settings.phaseProvider, loadEfforts])
320
+
321
+ // When the efforts of the selected model arrive, drop a stored effort the
322
+ // model no longer offers (e.g. the user switched the model). A failed fetch
323
+ // (`failed: true`) keeps the stored effort: the model may just be
324
+ // temporarily unresolvable, and wiping the user's choice would be worse.
325
+ useEffect(() => {
326
+ const s = settingsRef.current
327
+ for (const kind of ['refinement', 'phase'] as const) {
328
+ const fields = GROUP_FIELDS[kind]
329
+ const model = s[fields.model]
330
+ const provider = s[fields.provider]
331
+ const effort = s[fields.effort]
332
+ if (effort === '' || model === '' || provider === '') continue
333
+ const opts = efforts[`${provider}/${model}`]
334
+ if (opts === undefined || opts.failed === true) continue
335
+ if (!opts.efforts.some((o) => o.id === effort)) {
336
+ void commitRef.current({ [fields.effort]: '' } as Partial<KanbanSettingsShape>)
337
+ }
338
+ }
339
+ }, [efforts])
175
340
 
176
341
  return (
177
- <div className="kb-settings">
178
- <label className="kb-field kb-field-row">
179
- <span>{tr('maxParallelWorkers')}</span>
180
- <input
181
- className="kb-input kb-input-number"
182
- type="number"
183
- min={1}
184
- step={1}
185
- value={settings.maxParallelWorkers}
186
- onChange={(e) => {
187
- const value = Math.max(1, Math.floor(Number(e.target.value) || 1))
188
- setSettings((s) => ({ ...s, maxParallelWorkers: value }))
189
- void commit({ maxParallelWorkers: value })
190
- }}
191
- />
192
- </label>
193
- <label className="kb-field kb-field-row">
194
- <span>{tr('defaultModel')}</span>
195
- <select
196
- className="kb-input"
197
- value={settings.defaultModel}
198
- onChange={(e) => { void commit({ defaultModel: e.target.value }) }}
199
- >
200
- <option value="">—</option>
201
- {models.map((m) => (
202
- <option key={m.provider + '/' + m.id} value={m.id}>{m.name ?? m.id}</option>
203
- ))}
204
- </select>
205
- </label>
206
- {saved && <div className="kb-settings-saved">{tr('saved')}</div>}
207
- </div>
342
+ <li className="kb-settings-card" data-open={open ? '' : undefined}>
343
+ <button
344
+ type="button"
345
+ className="kb-settings-card-head"
346
+ aria-expanded={open}
347
+ aria-label={(open ? tr('settingsCollapse') : tr('settingsExpand')) + ':' + tr('settingsTitle')}
348
+ onClick={() => setOpen(!open)}
349
+ >
350
+ <span className="kb-settings-card-headText">
351
+ <span className="kb-settings-card-title">{tr('settingsTitle')}</span>
352
+ <span className="kb-settings-card-sub">{tr('settingsDescription')}</span>
353
+ </span>
354
+ <span className="kb-settings-card-chevron" data-open={open ? '' : undefined} aria-hidden="true">
355
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
356
+ <path d="M3.5 5.25L7 8.75L10.5 5.25" stroke="currentColor" strokeWidth="1.5"
357
+ strokeLinecap="round" strokeLinejoin="round" />
358
+ </svg>
359
+ </span>
360
+ </button>
361
+ {open && (
362
+ <div className="kb-settings-card-body">
363
+ <div className="kb-settings">
364
+ <label className="kb-field kb-field-row">
365
+ <span>{tr('maxParallelWorkers')}</span>
366
+ <input
367
+ className="kb-input kb-input-number"
368
+ type="number"
369
+ min={1}
370
+ step={1}
371
+ value={settings.maxParallelWorkers}
372
+ onChange={(e) => {
373
+ const value = Math.max(1, Math.floor(Number(e.target.value) || 1))
374
+ setSettings((s) => ({ ...s, maxParallelWorkers: value }))
375
+ void commit({ maxParallelWorkers: value })
376
+ }}
377
+ />
378
+ </label>
379
+ <SessionDefaultsGroup
380
+ kind="refinement"
381
+ settings={settings}
382
+ models={models}
383
+ presets={presets}
384
+ efforts={efforts}
385
+ tr={tr}
386
+ onCommit={commit}
387
+ loadEfforts={loadEfforts}
388
+ />
389
+ <SessionDefaultsGroup
390
+ kind="phase"
391
+ settings={settings}
392
+ models={models}
393
+ presets={presets}
394
+ efforts={efforts}
395
+ tr={tr}
396
+ onCommit={commit}
397
+ loadEfforts={loadEfforts}
398
+ />
399
+ {saved && <div className="kb-settings-saved">{tr('saved')}</div>}
400
+ </div>
401
+ </div>
402
+ )}
403
+ </li>
208
404
  )
209
405
  }
@@ -18,3 +18,43 @@ export async function listModels(ctx: Context): Promise<ModelOption[]> {
18
18
  }
19
19
  return out
20
20
  }
21
+
22
+ /** One selectable reasoning effort of a model (display vocabulary for the settings picker). */
23
+ export interface ReasoningEffortOption {
24
+ id: string
25
+ name: string
26
+ description?: string
27
+ }
28
+
29
+ export interface ReasoningOptions {
30
+ efforts: ReasoningEffortOption[]
31
+ defaultEffort?: string
32
+ }
33
+
34
+ /**
35
+ * Resolve the adapter-owned reasoning efforts of one exact provider/model
36
+ * route. On-demand (one model at a time) so the settings picker can offer the
37
+ * levels the selected model actually supports; failures yield an empty list.
38
+ */
39
+ export async function reasoningOptions(ctx: Context, provider: string, model: string): Promise<ReasoningOptions> {
40
+ const llm = ctx.get('llm') as
41
+ | { resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<{
42
+ reasoning?: { efforts?: Array<{ id: unknown; name: string; description?: string }>; defaultEffort?: unknown }
43
+ }> }
44
+ | undefined
45
+ if (llm === undefined) return { efforts: [] }
46
+ try {
47
+ const info = await llm.resolveModelInfo(provider, model)
48
+ const efforts = (info.reasoning?.efforts ?? []).map((e) => ({
49
+ id: String(e.id),
50
+ name: e.name,
51
+ ...(e.description !== undefined ? { description: e.description } : {}),
52
+ }))
53
+ return {
54
+ efforts,
55
+ ...(info.reasoning?.defaultEffort !== undefined ? { defaultEffort: String(info.reasoning.defaultEffort) } : {}),
56
+ }
57
+ } catch {
58
+ return { efforts: [] }
59
+ }
60
+ }
package/src/server/rpc.ts CHANGED
@@ -6,10 +6,10 @@
6
6
  import type { IncomingMessage, ServerResponse } from 'node:http'
7
7
  import type { Context } from '@deepseek-ai/cordis'
8
8
  import type {} from '@deepseek-ai/dsh-host-webserver'
9
- import type { Lane } from '../shared/card.js'
9
+ import type { KanbanSettingsShape, Lane } from '../shared/card.js'
10
10
  import type { KanbanRunner } from './runner.js'
11
11
  import type { KanbanSettingsFace } from './settings.js'
12
- import { listModels } from './models.js'
12
+ import { listModels, reasoningOptions } from './models.js'
13
13
 
14
14
  const API_PREFIX = '/plugins/@fonlan/dsh-task-kanban/api'
15
15
 
@@ -152,16 +152,45 @@ export function registerApiRoutes(
152
152
 
153
153
  route('settings.get', async () => settings.get())
154
154
 
155
+ /** Every settings field the client may write (per-session-type defaults included). */
156
+ const SETTING_STRING_FIELDS = [
157
+ 'refinementModel',
158
+ 'refinementProvider',
159
+ 'refinementReasoningEffort',
160
+ 'refinementPreset',
161
+ 'phaseModel',
162
+ 'phaseProvider',
163
+ 'phaseReasoningEffort',
164
+ 'phasePreset',
165
+ ] as const
166
+
155
167
  route('settings.set', async (p) => {
156
- const patch: { maxParallelWorkers?: number; defaultModel?: string } = {}
168
+ const patch: Partial<KanbanSettingsShape> = {}
157
169
  if (typeof p.maxParallelWorkers === 'number') patch.maxParallelWorkers = p.maxParallelWorkers
158
- if (typeof p.defaultModel === 'string') patch.defaultModel = p.defaultModel
170
+ for (const key of SETTING_STRING_FIELDS) {
171
+ if (typeof p[key] === 'string') patch[key] = p[key]
172
+ }
159
173
  await settings.update(patch)
160
174
  return settings.get()
161
175
  })
162
176
 
163
177
  route('models.list', async () => listModels(ctx))
164
178
 
179
+ route('presets.list', async () => {
180
+ const presets = ctx.get('agentPresets') as
181
+ | { list(): Promise<Array<{ id: string; name?: string; description?: string }>> }
182
+ | undefined
183
+ if (presets === undefined) return []
184
+ const rows = await presets.list()
185
+ return rows.map((r) => ({ id: r.id, name: r.name, description: r.description }))
186
+ })
187
+
188
+ route('reasoning.options', async (p) => {
189
+ const provider = requireString(p.provider, 'provider')
190
+ const model = requireString(p.model, 'model')
191
+ return reasoningOptions(ctx, provider, model)
192
+ })
193
+
165
194
  return () => {
166
195
  for (const d of disposers) d()
167
196
  }