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/lib/client.js +280 -147
- package/package.json +1 -1
- package/src/ask-tool.js +47 -5
- package/src/client/AskHistoryRow.module.css +47 -0
- package/src/client/AskHistoryRow.tsx +134 -0
- package/src/client/index.ts +15 -0
- package/src/client/locales.ts +6 -0
- package/src/settings.js +11 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-better-ask-user",
|
|
3
|
-
"version": "0.1.
|
|
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))
|
|
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(
|
|
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
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -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',
|
package/src/client/locales.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
+
}
|