dsh-better-ask-user 0.1.5 → 0.1.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-better-ask-user",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Independent ask_user_question plugin: configurable wait, input-extended countdown, markdown/mobile card, native or default timeout behavior, and PTC deadline exemption.",
5
5
  "license": "GPL-3.0",
6
6
  "repository": {
package/src/ask-tool.js CHANGED
@@ -5,12 +5,16 @@ import { buildDefaultAnswer } from './default-answer.js'
5
5
  /** Model-visible notice for the native pending result. */
6
6
  export const PENDING_QUESTION_NOTICE = 'No answer batch arrived before the timeout. This is pending, not a skipped answer. Continue useful independent work. The user can still answer; their reply will be a user message identified as answer_to_pending_question with this callId and the original questions. Do not treat this as permission.'
7
7
 
8
+ /** Model-visible warning for a host auto-selected default batch. */
9
+ export const AUTO_SELECTED_WARNING = 'These answers were auto-selected by the host timeout behavior, not submitted by the user. Do not treat them as user confirmation.'
10
+
8
11
  function questionRequest(questions, exec) {
9
12
  return {
10
13
  questions: questions.map(question => ({
11
14
  id: question.id,
12
15
  question: question.question,
13
16
  ...(question.header !== undefined ? { header: question.header } : {}),
17
+ ...(question.detail !== undefined ? { detail: question.detail } : {}),
14
18
  ...(question.options !== undefined ? {
15
19
  options: question.options.map(option => ({ ...option })),
16
20
  } : {}),
@@ -36,14 +40,51 @@ function answerResult(result) {
36
40
  selected: [...answer.selected],
37
41
  ...(answer.custom !== undefined ? { custom: answer.custom } : {}),
38
42
  })),
43
+ ...(result.autoSelected === true ? { autoSelected: true, warning: AUTO_SELECTED_WARNING } : {}),
44
+ }
45
+ }
46
+
47
+ /** Add the optional detail field to the model-visible question schema. */
48
+ function parametersWithDetail(parameters) {
49
+ if (parameters === undefined) return parameters
50
+ const next = structuredClone(parameters)
51
+ const properties = next?.properties?.questions?.items?.properties
52
+ if (properties !== undefined && properties.detail === undefined) {
53
+ properties.detail = {
54
+ type: 'string',
55
+ description: 'Optional longer detail or context rendered as sanitized markdown below the question.',
56
+ }
57
+ }
58
+ return next
59
+ }
60
+
61
+ /** Allow the additive auto-selected warning fields on the normal answer branch. */
62
+ function extendAutoSelectedSchema(schema) {
63
+ const branches = Array.isArray(schema?.oneOf) ? schema.oneOf : [schema]
64
+ for (const branch of branches) {
65
+ const properties = branch?.properties
66
+ if (properties?.answers === undefined) continue
67
+ if (properties.autoSelected === undefined) {
68
+ properties.autoSelected = {
69
+ type: 'boolean',
70
+ description: 'True when the host auto-selected the default batch after the timeout; not a user answer.',
71
+ }
72
+ }
73
+ if (properties.warning === undefined) {
74
+ properties.warning = { type: 'string', description: 'Model-visible warning for an auto-selected batch.' }
75
+ }
39
76
  }
77
+ return schema
40
78
  }
41
79
 
42
80
  /** The timed path can return the official pending shape; keep it valid. */
43
81
  function outputWithPending(base) {
44
82
  const output = { ...base.output }
45
- const schema = structuredClone(base.output.schema)
46
- if (Array.isArray(schema?.oneOf) && schema.oneOf.some(branch => branch?.properties?.pending !== undefined)) return output
83
+ const schema = extendAutoSelectedSchema(structuredClone(base.output.schema))
84
+ if (Array.isArray(schema?.oneOf) && schema.oneOf.some(branch => branch?.properties?.pending !== undefined)) {
85
+ output.schema = schema
86
+ return output
87
+ }
47
88
  output.schema = {
48
89
  oneOf: [
49
90
  schema,
@@ -71,9 +112,10 @@ function outputWithPending(base) {
71
112
  */
72
113
  export function createBetterAskTool(ctx, base, readConfig) {
73
114
  const output = outputWithPending(base)
115
+ const parameters = parametersWithDetail(base.parameters)
74
116
 
75
117
  const execute = async (args, exec) => {
76
- const violations = validateJsonSchemaValue(base.parameters, args, '')
118
+ const violations = validateJsonSchemaValue(parameters, args, '')
77
119
  if (violations.length > 0) throw new ToolArgsError(violations)
78
120
  validateQuestionIds(args.questions)
79
121
 
@@ -113,5 +155,5 @@ export function createBetterAskTool(ctx, base, readConfig) {
113
155
  return answerResult(result)
114
156
  }
115
157
 
116
- return { ...base, output, execute }
117
- }
158
+ return { ...base, parameters, output, execute }
159
+ }
@@ -0,0 +1,47 @@
1
+ .row {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: 8px;
5
+ min-height: 24px;
6
+ min-width: 0;
7
+ color: var(--dsw-alias-label-secondary);
8
+ }
9
+ .icon {
10
+ display: inline-flex;
11
+ align-items: center;
12
+ flex: none;
13
+ color: var(--dsw-alias-label-tertiary);
14
+ }
15
+ .title {
16
+ flex: none;
17
+ font: var(--dsw-font-xs-strong-13);
18
+ color: var(--dsw-alias-label-secondary);
19
+ }
20
+ .summary {
21
+ min-width: 0;
22
+ flex: 1;
23
+ overflow: hidden;
24
+ text-overflow: ellipsis;
25
+ white-space: nowrap;
26
+ font: var(--dsw-font-xs-13);
27
+ color: var(--dsw-alias-label-tertiary);
28
+ }
29
+ .open {
30
+ flex: none;
31
+ cursor: pointer;
32
+ border: .5px solid var(--dsw-alias-border-l4);
33
+ border-radius: 999px;
34
+ background: var(--dsw-alias-bg-base);
35
+ color: var(--dsw-alias-label-secondary);
36
+ padding: 1px 8px;
37
+ font: var(--dsw-font-xxs-12);
38
+ line-height: 16px;
39
+ }
40
+ .open:hover {
41
+ background: var(--dsw-alias-interactive-bg-hover-solid);
42
+ color: var(--dsw-alias-label-primary);
43
+ }
44
+ .open:focus-visible {
45
+ outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
46
+ outline-offset: 1px;
47
+ }
@@ -0,0 +1,134 @@
1
+ /** History row for ask_user_question: compact summary plus a replay button. */
2
+ import { IconQuestionOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
3
+ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
4
+ import type { ToolCallViewProps, UserQuestionRecord } from '@deepseek-ai/dsh-client-ui-tool/client'
5
+ import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-questions/types'
6
+ import type { QuestionKey } from './locales.ts'
7
+ import css from './AskHistoryRow.module.css'
8
+
9
+ type AskHistoryRowProps = ToolCallViewProps & PropsLocale<'question'> & {
10
+ revealPanel: (callId: string) => boolean
11
+ reviewPanel: (callId: string, record: UserQuestionRecord) => boolean
12
+ }
13
+
14
+ interface AnswerEntry {
15
+ id: string
16
+ selected: string[]
17
+ custom?: string
18
+ }
19
+
20
+ function isRecord(value: unknown): value is Record<string, unknown> {
21
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
22
+ }
23
+
24
+ function parseJson(text: string): unknown {
25
+ try {
26
+ return JSON.parse(text)
27
+ } catch {
28
+ return undefined
29
+ }
30
+ }
31
+
32
+ function resultText(content: readonly unknown[]): string | undefined {
33
+ const parts: string[] = []
34
+ for (const block of content) {
35
+ if (!isRecord(block) || block.type !== 'text' || typeof block.text !== 'string') continue
36
+ parts.push(block.text)
37
+ }
38
+ return parts.length === 0 ? undefined : parts.join('\n')
39
+ }
40
+
41
+ function questionList(argsRaw: string): AskUserQuestionItem[] | undefined {
42
+ const parsed = parseJson(argsRaw)
43
+ if (!isRecord(parsed) || !Array.isArray(parsed.questions) || parsed.questions.length === 0) return undefined
44
+ const questions: AskUserQuestionItem[] = []
45
+ for (const candidate of parsed.questions) {
46
+ if (!isRecord(candidate) || typeof candidate.id !== 'string' || typeof candidate.question !== 'string') return undefined
47
+ questions.push({
48
+ id: candidate.id,
49
+ question: candidate.question,
50
+ ...(typeof candidate.header === 'string' ? { header: candidate.header } : {}),
51
+ ...(Array.isArray(candidate.options)
52
+ ? {
53
+ options: candidate.options.flatMap(option => isRecord(option) && typeof option.label === 'string'
54
+ ? [{
55
+ label: option.label,
56
+ ...(typeof option.description === 'string' ? { description: option.description } : {}),
57
+ }]
58
+ : []),
59
+ }
60
+ : {}),
61
+ ...(typeof candidate.multi_select === 'boolean' ? { multi_select: candidate.multi_select } : {}),
62
+ })
63
+ }
64
+ return questions
65
+ }
66
+
67
+ function answerList(text: string | undefined): AnswerEntry[] | undefined {
68
+ if (text === undefined) return undefined
69
+ const parsed = parseJson(text)
70
+ if (!isRecord(parsed) || !Array.isArray(parsed.answers)) return undefined
71
+ const answers: AnswerEntry[] = []
72
+ for (const candidate of parsed.answers) {
73
+ if (!isRecord(candidate)
74
+ || typeof candidate.id !== 'string'
75
+ || !Array.isArray(candidate.selected)
76
+ || !candidate.selected.every(value => typeof value === 'string')
77
+ || (candidate.custom !== undefined && typeof candidate.custom !== 'string')) return undefined
78
+ answers.push({
79
+ id: candidate.id,
80
+ selected: candidate.selected,
81
+ ...(candidate.custom === undefined ? {} : { custom: candidate.custom }),
82
+ })
83
+ }
84
+ return answers
85
+ }
86
+
87
+ function isPending(text: string | undefined): boolean {
88
+ if (text === undefined) return false
89
+ const parsed = parseJson(text)
90
+ return isRecord(parsed) && parsed.pending === true
91
+ }
92
+
93
+ /** The compact history re-entry for one ask call, top-level or nested. */
94
+ export function AskHistoryRow({
95
+ callId,
96
+ block,
97
+ phase,
98
+ revealPanel,
99
+ reviewPanel,
100
+ t,
101
+ }: AskHistoryRowProps): React.JSX.Element {
102
+ const argsRaw = 'kind' in block ? block.call?.argsRaw ?? '' : block.argsRaw
103
+ const output = 'kind' in block ? resultText(block.content) : undefined
104
+ const questions = questionList(argsRaw)
105
+ const answers = answerList(output)
106
+ const record: UserQuestionRecord | undefined = questions !== undefined && answers !== undefined
107
+ ? { questions, answers }
108
+ : undefined
109
+ const summary = questions?.[0]?.question.split('\n').find(line => line.trim() !== '')?.trim() ?? t('history.title')
110
+ const review = record !== undefined
111
+ const action = review
112
+ ? () => { reviewPanel(callId, record) }
113
+ : isPending(output) || phase === 'start'
114
+ ? () => { revealPanel(callId) }
115
+ : undefined
116
+
117
+ return (
118
+ <div className={css.row}>
119
+ <span className={css.icon} aria-hidden="true"><IconQuestionOutlineRegular /></span>
120
+ <span className={css.title}>{t('history.title')}</span>
121
+ <span className={css.summary} title={summary}>{summary}</span>
122
+ {action !== undefined && (
123
+ <button
124
+ type="button"
125
+ className={css.open}
126
+ onClick={action}
127
+ aria-label={review ? t('history.review') : t('history.reopen')}
128
+ >
129
+ {review ? t('history.review') : t('history.reopen')}
130
+ </button>
131
+ )}
132
+ </div>
133
+ )
134
+ }
@@ -27,6 +27,7 @@ import type { ToolCallId } from '@deepseek-ai/dsh-llm'
27
27
  import { brandString } from '@deepseek-ai/dsh-brand'
28
28
  import { createWaterfallRequest, PendingQuestion, type BetterAskConfig, type QuestionRpcChannel } from './contract/slots.ts'
29
29
  import { createQuestionDraftStore } from './draft-store.ts'
30
+ import { AskHistoryRow } from './AskHistoryRow.tsx'
30
31
  import { QuestionComposer } from './QuestionComposer.tsx'
31
32
  import { questionReplyDefinition } from './question-reply.ts'
32
33
  import { QuestionReplyView } from './QuestionReplyView.tsx'
@@ -414,6 +415,20 @@ export function apply(ctx: ClientContext): void {
414
415
  } satisfies UserQuestionPanels)
415
416
  ctx.effect(() => disposePanels, 'ui-user-questions: answer panels')
416
417
  ctx.effect(() => publishContinuedQuestions(ctx, cards), 'ui-user-questions: continued questions')
418
+ ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
419
+ name: 'tool.call.toolview',
420
+ key: 'ask_user_question',
421
+ locale: NS,
422
+ // The shipped `@deepseek-ai/dsh-client-ui-tool` already registers
423
+ // `ask-question-toolview` for this key at priority 0, and the slot registry
424
+ // rejects a second entry at the same priority (which failed the whole client
425
+ // boot). A lower priority shadows it: lowest renders.
426
+ priority: -1,
427
+ inject: (sessionId: SessionId) => ({
428
+ revealPanel: (callId: string) => ctx.get('userQuestionPanels')?.reveal(sessionId, callId) ?? false,
429
+ reviewPanel: (callId: string, record: UserQuestionRecord) => ctx.get('userQuestionPanels')?.review(sessionId, callId, record) ?? false,
430
+ }),
431
+ }, AskHistoryRow))
417
432
  ctx.slots.inject('conversation.composer', () => ctx.slots.register(
418
433
  {
419
434
  name: 'conversation.composer',
@@ -7,6 +7,9 @@ export const zh = {
7
7
  'error.unavailable': '当前无法提交,请稍候再试。',
8
8
  'error.resubmit': '回答未送达,工作已继续,请再提交一次。',
9
9
  'status.sent': '回答已发送;面板未能关闭。',
10
+ 'history.title': '问题',
11
+ 'history.review': '查看',
12
+ 'history.reopen': '打开',
10
13
  'wait.takeTime': '慢慢回答',
11
14
  'wait.countdown': '{seconds} 秒后继续工作',
12
15
  'wait.paused': '已暂停 · 剩余 {seconds} 秒',
@@ -47,6 +50,9 @@ export const en = {
47
50
  'error.unavailable': 'Cannot submit right now; try again in a moment.',
48
51
  'error.resubmit': 'The answer did not arrive before work continued; submit it again.',
49
52
  'status.sent': 'Reply sent; the panel could not close.',
53
+ 'history.title': 'Question',
54
+ 'history.review': 'Review',
55
+ 'history.reopen': 'Open',
50
56
  'wait.takeTime': 'Take time',
51
57
  'wait.countdown': 'Continuing in {seconds}s',
52
58
  'wait.paused': 'Paused · {seconds}s remaining',
package/src/settings.js CHANGED
@@ -12,9 +12,11 @@ function hasOwn(value, key) {
12
12
  return value !== null && typeof value === 'object' && Object.hasOwn(value, key)
13
13
  }
14
14
 
15
- function descriptor(service, ns) {
15
+ async function descriptor(service, ns) {
16
16
  try {
17
- return service.describe(ns)
17
+ const rows = await service.describe()
18
+ if (!Array.isArray(rows)) return undefined
19
+ return rows.find(row => row?.ns === ns)
18
20
  } catch {
19
21
  return undefined
20
22
  }
@@ -36,25 +38,25 @@ export async function migrateLegacyQuestionTimeout(service) {
36
38
  return { status: 'unavailable' }
37
39
  }
38
40
  const deadline = Date.now() + WAIT_MS
39
- let source = descriptor(service, LEGACY_GOAL_NS)
40
- let target = descriptor(service, ASK_NS)
41
+ let source = await descriptor(service, LEGACY_GOAL_NS)
42
+ let target = await descriptor(service, ASK_NS)
41
43
  while ((source === undefined || target === undefined) && Date.now() < deadline) {
42
44
  await sleep(WAIT_INTERVAL_MS)
43
- source = descriptor(service, LEGACY_GOAL_NS)
44
- target = descriptor(service, ASK_NS)
45
+ source = await descriptor(service, LEGACY_GOAL_NS)
46
+ target = await descriptor(service, ASK_NS)
45
47
  }
46
48
  if (source === undefined || target === undefined) return { status: 'pending' }
47
49
  if (!hasOwn(source.user, 'questionTimeoutSeconds')) return { status: 'no-change' }
48
50
  if (hasOwn(target.user, 'questionTimeoutSeconds')) return { status: 'no-change' }
49
51
  const value = source.value?.questionTimeoutSeconds
50
52
  if (!validSeconds(value)) return { status: 'no-change' }
51
- if (target.writable !== true) return { status: 'pending' }
53
+ if (service.writable === false) return { status: 'pending' }
52
54
  try {
53
55
  await service.update(ASK_NS, { questionTimeoutSeconds: value }, target.revision)
54
56
  } catch (error) {
55
57
  return { status: 'error', error: error instanceof Error ? error.message : String(error) }
56
58
  }
57
- const after = descriptor(service, ASK_NS)
59
+ const after = await descriptor(service, ASK_NS)
58
60
  return after?.user?.questionTimeoutSeconds === value
59
61
  ? { status: 'complete', value }
60
62
  : { status: 'pending', value }
@@ -76,4 +78,4 @@ export function mountLegacyMigration(ctx) {
76
78
  inner.logger?.warn?.('better-ask-user: legacy question wait migration failed: ' + String(error))
77
79
  })
78
80
  })
79
- }
81
+ }