feat(gui): add ask-user question composer

This commit is contained in:
Yichen Jiang
2026-07-22 23:39:50 +08:00
parent b51d2b3d67
commit 03889cee1a
58 changed files with 1905 additions and 115 deletions

View File

@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-client-ui-question
Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot.
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
Selection state is local to a component keyed by the request rpcId. A replay with the same id preserves a still-mounted draft, while `question/resolved` from the host removes the composer. The host remains authoritative: successful HTTP delivery does not remove pending state locally.
## Model Experience
Indirectly, through `dsh-tool-ask-user`; that package owns the model-visible tool schema and structured result.
#### KV Cache effect
No direct invalidation; `dsh-tool-ask-user` owns the model-visible tool call and result.
## Known Limitations and Deferred Work
- **Unsubmitted drafts are not durable** — reconnect resync or a full page reload restores the host-owned pending request with the same rpcId, but a composer unmount resets local option and custom-text drafts.
- **One request owns the composer at a time** — later pending requests remain in the session snapshot and become visible after the earlier request resolves.

View File

@@ -0,0 +1,66 @@
{
"name": "@deepseek-ai/dsh-client-ui-question",
"description": "Web ask_user_question feature: host tool mount plus composer-takeover question UI",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"clsx": "^2.0.0",
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -0,0 +1,329 @@
.frame {
display: flex;
justify-content: center;
padding: 6px 24px 10px;
}
.card {
width: 100%;
max-width: 720px;
padding: 14px 16px 12px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 18px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv1-blur);
color: var(--dsw-alias-label-primary);
}
.card,
.card * {
box-sizing: border-box;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 8px;
}
.headingBlock {
min-width: 0;
padding: 1px 2px;
}
.eyebrow {
margin-bottom: 2px;
color: var(--dsw-alias-label-tertiary);
font-size: 11px;
line-height: 16px;
}
.title {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px;
margin: 0;
font-size: 16px;
line-height: 22px;
font-weight: 600;
}
.multiSelectHint {
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 20px;
font-weight: 400;
white-space: nowrap;
}
.headerActions,
.footerActions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.progress {
padding: 0 6px;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 24px;
white-space: nowrap;
}
.iconButton {
display: grid;
place-items: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.iconButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.iconButton:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: default;
}
.options {
display: flex;
flex-direction: column;
gap: 4px;
}
.option {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 42px;
padding: 5px 8px;
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition: background-color 120ms ease, border-color 120ms ease;
}
.option:hover:not(:disabled),
.optionSelected {
background: var(--dsw-alias-interactive-bg-hover);
}
.optionSelected {
border-color: var(--dsw-alias-border-l2);
}
.option:disabled,
.customTrigger:disabled {
cursor: default;
}
.number {
display: grid;
place-items: center;
flex: 0 0 28px;
width: 28px;
height: 28px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
}
.optionCopy {
min-width: 0;
flex: 1;
}
.optionLine {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 2px 6px;
}
.optionLabel {
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.badge {
padding: 0 6px;
border-radius: 999px;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-secondary);
font-size: 11px;
line-height: 18px;
}
.description {
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 20px;
font-weight: 400;
}
.choiceIcon {
display: grid;
place-items: center;
width: 20px;
color: var(--dsw-alias-label-tertiary);
}
.custom {
border: 1px solid transparent;
border-radius: 12px;
}
.customOpen {
border-color: var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-module-platform);
}
.customOptionless {
border: none;
background: transparent;
}
.customTrigger {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 42px;
padding: 5px 8px;
border: none;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 20px;
text-align: left;
cursor: pointer;
}
.customTrigger:hover:not(:disabled) {
color: var(--dsw-alias-label-primary);
}
.customInput {
display: block;
width: calc(100% - 20px);
min-height: 54px;
max-height: 140px;
margin: 0 10px 10px;
padding: 7px 10px;
resize: vertical;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
outline: none;
background: var(--dsw-specific-input-major);
color: var(--dsw-alias-label-primary);
caret-color: var(--dsw-alias-state-business-primary);
font: inherit;
font-size: 13px;
line-height: 20px;
}
.customInput:focus {
border-color: var(--dsw-alias-state-business-primary);
}
.customInput::placeholder {
color: var(--dsw-alias-label-caption);
}
.customOptionless .customInput {
width: 100%;
min-height: 58px;
margin: 0;
background: var(--dsw-alias-bg-module-platform);
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 8px;
padding: 0 2px;
}
.feedback {
min-height: 16px;
color: var(--dsw-alias-state-error-primary);
font-size: 11px;
line-height: 16px;
}
@media (max-width: 720px) {
.frame {
padding: 6px 10px 10px;
}
.card {
padding: 12px 10px 10px;
border-radius: 16px;
}
.header {
display: block;
}
.headerActions {
justify-content: flex-end;
margin-top: 8px;
}
.headingBlock {
padding: 0 2px;
}
.title {
font-size: 15px;
line-height: 21px;
}
.option,
.customTrigger {
align-items: flex-start;
gap: 8px;
padding: 6px;
}
.choiceIcon {
margin-top: 3px;
}
.footer {
align-items: flex-end;
}
.footerActions {
flex-shrink: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.option {
transition: none;
}
}

View File

@@ -0,0 +1,308 @@
import { useState, type KeyboardEvent } from 'react'
import clsx from 'clsx'
import type { QuestionResponsePayload } from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
import {
Button, IconCheckOutline16, IconChevronLeftOutline14, IconChevronRightOutline14,
IconCloseOutline16, IconEditOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { QuestionComposerOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import css from './QuestionComposer.module.css'
type QuestionInteraction = Extract<PendingInteraction, { kind: 'question' }>
type Answer = QuestionResponsePayload['answer']
interface DraftAnswer {
selected: string[]
custom: string
customOpen: boolean
skipped: boolean
}
/** Actions assembled from the session object layer. */
export interface QuestionComposerInjected {
actions: {
answer(interaction: QuestionInteraction, answer: Answer): Promise<void>
cancel(interaction: QuestionInteraction): Promise<void>
}
}
/** Full question-composer props. */
export type QuestionComposerProps = QuestionComposerOwnerProps & QuestionComposerInjected
/**
* Split the conventional recommendation suffix without changing the answer value.
* @param label - Original option label returned if selected.
* @returns Display label plus recommendation state.
*/
export function parseRecommendedLabel(label: string): { label: string; recommended: boolean } {
const suffix = /\s*(?:\((?:recommended|推荐)\)|(?:recommended|推荐))\s*$/i
return suffix.test(label)
? { label: label.replace(suffix, ''), recommended: true }
: { label, recommended: false }
}
/**
* Remove a conventional multi-select suffix so the hint can be styled separately.
* @param title - Question title supplied by the interaction request.
* @returns Question title without a trailing multi-select marker.
*/
export function parseQuestionTitle(title: string): string {
return title.replace(/\s*[(]可多选[)]\s*$/, '')
}
/** Return whether a textarea key event belongs to an active IME composition. */
function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
}
/**
* Composer takeover boundary; rpcId keys local drafts while same-id replay preserves them.
* @param props - Pending interaction and scoped answer/cancel actions.
* @returns The question flow for this request.
*/
export function QuestionComposer(props: QuestionComposerProps) {
return <QuestionFlow key={props.interaction.rpcId} {...props} />
}
function QuestionFlow({ interaction, actions }: QuestionComposerProps) {
const questions = interaction.questions
const [index, setIndex] = useState(0)
const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(question => ({
selected: [], custom: '', customOpen: (question.options?.length ?? 0) === 0, skipped: false,
})))
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
const [error, setError] = useState<string | null>(null)
const question = questions[index]!
const draft = drafts[index]!
const hasOptions = (question.options?.length ?? 0) > 0
const cancelFlow = (): void => {
setBusy('cancel')
setError(null)
void actions.cancel(interaction).catch((cause: unknown) => {
setBusy(null)
setError(cause instanceof Error ? cause.message : String(cause))
})
}
const updateDraft = (update: (current: DraftAnswer) => DraftAnswer): void => {
setDrafts(current => current.map((item, itemIndex) => itemIndex === index ? update(item) : item))
setError(null)
}
const choose = (label: string): void => {
updateDraft((current) => {
const selected = question.multiSelect === true
? current.selected.includes(label)
? current.selected.filter(item => item !== label)
: [...current.selected, label]
: [label]
return { selected, custom: '', customOpen: false, skipped: false }
})
if (question.multiSelect !== true && index < questions.length - 1) {
setIndex(current => current + 1)
}
}
const openCustom = (): void => {
updateDraft(current => ({ ...current, selected: [], customOpen: true, skipped: false }))
}
const answered = (item: DraftAnswer): boolean =>
item.selected.length > 0 || item.custom.trim() !== ''
const completed = (item: DraftAnswer): boolean => answered(item) || item.skipped
const submitDrafts = (values: DraftAnswer[]): void => {
const missing = values.findIndex(item => !completed(item))
if (missing >= 0) {
setIndex(missing)
setError('请先完成这道问题。')
return
}
const answer: Answer = {
answers: questions.map((item, itemIndex) => {
const value = values[itemIndex] as DraftAnswer
if (value.skipped) return { id: item.id, selected: [] }
const custom = value.custom.trim()
return {
id: item.id,
selected: custom === '' ? value.selected : [],
...(custom === '' ? {} : { custom }),
}
}),
}
setBusy('answer')
setError(null)
void actions.answer(interaction, answer).catch((cause: unknown) => {
setBusy(null)
setError(cause instanceof Error ? cause.message : String(cause))
})
}
const continueFlow = (): void => {
if (!answered(draft)) {
setError('请选择一个选项或填写自定义答案。')
return
}
if (index < questions.length - 1) {
setIndex(current => current + 1)
setError(null)
return
}
submitDrafts(drafts)
}
const skipQuestion = (): void => {
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index
? {
selected: [], custom: '',
customOpen: (question.options?.length ?? 0) === 0,
skipped: true,
}
: item)
setDrafts(nextDrafts)
setError(null)
if (index < questions.length - 1) {
setIndex(current => current + 1)
return
}
submitDrafts(nextDrafts)
}
return (
<div className={css.frame} data-question-rpc-id={interaction.rpcId}>
<section className={css.card} aria-labelledby={`question-${interaction.rpcId}-${String(index)}`}>
<header className={css.header}>
<div className={css.headingBlock}>
{question.header !== undefined && <div className={css.eyebrow}>{question.header}</div>}
<h2 className={css.title} id={`question-${interaction.rpcId}-${String(index)}`}>
<span>{question.multiSelect === true
? parseQuestionTitle(question.question)
: question.question}</span>
{question.multiSelect === true && <span className={css.multiSelectHint}></span>}
</h2>
</div>
<div className={css.headerActions}>
<span className={css.progress}>{index + 1} / {questions.length}</span>
<button
type="button" className={css.iconButton} aria-label="上一题"
disabled={index === 0 || busy !== null}
onClick={() => { setIndex(index - 1); setError(null) }}
>
<IconChevronLeftOutline14 />
</button>
<button
type="button" className={css.iconButton} aria-label="下一题"
disabled={index === questions.length - 1 || busy !== null}
onClick={() => { setIndex(index + 1); setError(null) }}
>
<IconChevronRightOutline14 />
</button>
<button
type="button" className={css.iconButton} aria-label="放弃整组问题"
title="放弃整组问题"
disabled={busy !== null} onClick={cancelFlow}
>
<IconCloseOutline16 />
</button>
</div>
</header>
<div className={css.options} role={question.multiSelect === true ? 'group' : 'radiogroup'}>
{(question.options ?? []).map((option, optionIndex) => {
const selected = draft.selected.includes(option.label)
const display = parseRecommendedLabel(option.label)
return (
<button
type="button" key={`${option.label}-${String(optionIndex)}`}
className={clsx(css.option, selected && css.optionSelected)}
role={question.multiSelect === true ? 'checkbox' : 'radio'}
aria-checked={selected}
aria-label={display.label}
disabled={busy !== null}
onClick={() => { choose(option.label) }}
onKeyDown={(event) => {
if (event.key !== 'Enter' || !drafts.every(completed)) return
event.preventDefault()
submitDrafts(drafts)
}}
>
<span className={css.number}>{optionIndex + 1}</span>
<span className={css.optionCopy}>
<span className={css.optionLine}>
<span className={css.optionLabel}>{display.label}</span>
{display.recommended && <span className={css.badge}></span>}
{option.description !== undefined && (
<span className={css.description}>{option.description}</span>
)}
</span>
</span>
<span className={css.choiceIcon}>
{selected ? <IconCheckOutline16 /> : <IconChevronRightOutline14 />}
</span>
</button>
)
})}
<div className={clsx(
css.custom,
draft.customOpen && css.customOpen,
!hasOptions && css.customOptionless,
)}>
{hasOptions && (
<button
type="button" className={css.customTrigger}
disabled={busy !== null} onClick={openCustom}
aria-expanded={draft.customOpen}
>
<span className={css.number}><IconEditOutline16 /></span>
<span></span>
</button>
)}
{draft.customOpen && (
<textarea
autoFocus
className={css.customInput}
value={draft.custom}
disabled={busy !== null}
rows={2}
placeholder="输入你的答案"
onChange={(event) => {
const value = event.target.value
updateDraft(current => ({
...current, selected: [], custom: value, customOpen: true, skipped: false,
}))
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && !isComposing(event)) {
event.preventDefault()
continueFlow()
}
}}
/>
)}
</div>
</div>
<footer className={css.footer}>
<div className={css.feedback} role="status">{error}</div>
<div className={css.footerActions}>
<Button variant="ghost" size="sm" disabled={busy !== null} onClick={skipQuestion}>
</Button>
<Button
variant="primary" size="sm"
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
>
{busy === 'answer'
? '正在提交…'
: index === questions.length - 1 ? '提交' : '下一题'}
</Button>
</div>
</footer>
</section>
</div>
)
}

View File

@@ -0,0 +1,47 @@
/**
* Web question plugin, browser half: registers a composer replacement for
* pending ask_user_question requests.
*/
import type { Context } from 'cordis'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { QuestionComposer, type QuestionComposerInjected } from './QuestionComposer.tsx'
export { QuestionComposer, parseRecommendedLabel } from './QuestionComposer.tsx'
export type { QuestionComposerInjected, QuestionComposerProps } from './QuestionComposer.tsx'
/** Required browser services. */
export const inject = ['slots', 'sessions']
/**
* Register the question composer into the conversation-owned keyed slot.
* @param ctx - Browser plugin context carrying slots and sessions.
*/
export function apply(ctx: Context): void {
const slots = ctx.get('slots')
const sessions = ctx.get('sessions') as SessionsService | undefined
if (slots === undefined || sessions === undefined) {
throw new Error('ui-question: slots and sessions services are required')
}
slots.register<'conversation.composer', QuestionComposerInjected>('conversation.composer', QuestionComposer, {
key: 'question',
inject(binding): QuestionComposerInjected {
const session = sessions.manager.get(binding.sessionId as SessionId)
return {
actions: {
async answer(interaction, answer) {
const receipt = await session.answerQuestion(interaction.rpcId, answer)
if (!receipt.accepted) {
throw new Error(`question response rejected: ${receipt.reason}`)
}
},
async cancel(interaction) {
const receipt = await session.cancelQuestion(interaction.rpcId)
if (!receipt.accepted) {
throw new Error(`question cancellation rejected: ${receipt.reason}`)
}
},
},
}
},
})
}

View File

@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Readonly<Record<string, string>>
export default classes
}

View File

@@ -0,0 +1,17 @@
/**
* Web question plugin, node half: enabling this UI feature also exposes the
* model-facing ask_user_question tool on the host composition.
*/
import type { Context } from 'cordis'
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
/** Host services required by the model-facing tool. */
export const inject = ['tools', 'userInteraction']
/**
* Mount ask_user_question for hosts that selected the Web question plugin.
* @param ctx - Host plugin context carrying tools and userInteraction.
*/
export function apply(ctx: Context): void {
toolAskUser.apply(ctx)
}

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-question`.
* @module @deepseek-ai/dsh-client-ui-question/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-question'
/** Cordis companion plugin name. */
export const name = 'client-ui-question-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: tool and slot registrations are effects
* owned and observed by their respective registries; the host pending table is
* exercised through the public wire protocol.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns The installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,58 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '../src/client/index.ts'
type QuestionInteraction = Extract<PendingInteraction, { kind: 'question' }>
function interaction(): QuestionInteraction {
return {
kind: 'question', rpcId: RpcId('question-1'),
questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }],
}
}
describe('ui-question browser plugin', () => {
it('declares its services and fails loud without them', () => {
expect(inject).toEqual(['slots', 'sessions'])
expect(() => { apply(new Context()) }).toThrow(/slots and sessions services are required/)
})
it('registers scoped answer and cancel actions, including rejected receipts', async () => {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const answerQuestion = vi.fn()
.mockResolvedValueOnce({ accepted: true })
.mockResolvedValueOnce({ accepted: false, reason: 'not-pending' })
const cancelQuestion = vi.fn()
.mockResolvedValueOnce({ accepted: true })
.mockResolvedValueOnce({ accepted: false, reason: 'bad-response' })
ctx.provide('sessions', {
manager: { get: vi.fn(() => ({ answerQuestion, cancelQuestion })) },
})
const slots = ctx.get('slots') as SlotsService
slots.define('conversation.composer', { kind: 'keyed', scope: 'session' })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const entry = slots.entries('conversation.composer')[0] as unknown as {
options: { inject(binding: { sessionId: SessionId }): { actions: {
answer: (item: QuestionInteraction, answer: { answers: { id: string; selected: string[] }[] }) => Promise<void>
cancel: (item: QuestionInteraction) => Promise<void>
} } }
}
const actions = entry.options.inject({ sessionId: 'session-1' as SessionId }).actions
const item = interaction()
const answer = { answers: [{ id: 'mode', selected: ['Fast'] }] }
await expect(actions.answer(item, answer)).resolves.toBeUndefined()
await expect(actions.answer(item, answer)).rejects.toThrow(/not-pending/)
await expect(actions.cancel(item)).resolves.toBeUndefined()
await expect(actions.cancel(item)).rejects.toThrow(/bad-response/)
expect(answerQuestion).toHaveBeenCalledWith(item.rpcId, answer)
expect(cancelQuestion).toHaveBeenCalledWith(item.rpcId)
await ctx.fiber.dispose()
})
})

View File

@@ -0,0 +1,28 @@
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { apply, inject } from '../src/index.ts'
let ctx: Context | undefined
afterEach(async () => {
await ctx?.fiber.dispose()
ctx = undefined
})
describe('ui-question node plugin', () => {
it('exposes ask_user_question only for the selected Web feature lifecycle', async () => {
ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
const feature = ctx.plugin({ inject: [...inject], apply })
await feature.await()
expect(ctx.tools.get('ask_user_question')).toBeDefined()
await feature.dispose()
expect(ctx.tools.get('ask_user_question')).toBeUndefined()
})
})

View File

@@ -0,0 +1,198 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import {
QuestionComposer, parseQuestionTitle, parseRecommendedLabel,
} from '../src/client/QuestionComposer.tsx'
afterEach(cleanup)
type Interaction = Extract<PendingInteraction, { kind: 'question' }>
function interaction(rpcId = 'question-1'): Interaction {
return {
kind: 'question',
rpcId: RpcId(rpcId),
questions: [
{
id: 'profile', header: '偏好', question: '选择候选人类型',
options: [
{ label: '工程落地型 (Recommended)', description: '优先工程交付。' },
{ label: '研究潜力型', description: '优先研究能力。' },
],
},
{
id: 'detail', question: '补充你的要求',
},
{
id: 'signals', question: '选择重要信号(可多选)', multiSelect: true,
options: [{ label: '系统设计' }, { label: '代码质量' }, { label: '产品判断' }],
},
],
}
}
describe('QuestionComposer', () => {
it('collects single, custom, and multi-select answers before one batch submit', () => {
const answer = vi.fn(() => Promise.resolve())
const cancel = vi.fn(() => Promise.resolve())
render(<QuestionComposer interaction={interaction()} actions={{ answer, cancel }} />)
expect(screen.getByText('1 / 3')).toBeTruthy()
expect(screen.getByText('推荐')).toBeTruthy()
expect(screen.getByText('工程落地型')).toBeTruthy()
fireEvent.keyDown(screen.getByRole('radio', { name: /工程落地型/ }), { key: 'Enter' })
expect(answer).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
expect(screen.getByText('2 / 3')).toBeTruthy()
expect(screen.queryByRole('button', { name: '填写答案' })).toBeNull()
const custom = screen.getByPlaceholderText('输入你的答案')
fireEvent.change(custom, { target: { value: '要能独立排查线上问题' } })
fireEvent.keyDown(custom, { key: 'Enter' })
expect(screen.getByText('3 / 3')).toBeTruthy()
expect(screen.getByText('选择重要信号')).toBeTruthy()
expect(screen.getByText('可多选')).toBeTruthy()
expect(screen.queryByText('(可多选)')).toBeNull()
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('checkbox', { name: '代码质量' }))
fireEvent.keyDown(screen.getByRole('checkbox', { name: '代码质量' }), { key: 'Enter' })
expect(answer).toHaveBeenCalledWith(interaction(), {
answers: [
{ id: 'profile', selected: ['工程落地型 (Recommended)'] },
{ id: 'detail', selected: [], custom: '要能独立排查线上问题' },
{ id: 'signals', selected: ['系统设计', '代码质量'] },
],
})
expect((screen.getByRole('button', { name: '正在提交…' }) as HTMLButtonElement).disabled).toBe(true)
})
it('skips individual questions without discarding earlier answers', () => {
const answer = vi.fn(() => Promise.resolve())
const cancel = vi.fn(() => Promise.resolve())
render(<QuestionComposer interaction={interaction()} actions={{ answer, cancel }} />)
expect((screen.getByText('下一题').closest('button') as HTMLButtonElement).disabled).toBe(true)
fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
expect(screen.getByText('2 / 3')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '跳过本题' }))
expect(screen.getByText('3 / 3')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '跳过本题' }))
expect(cancel).not.toHaveBeenCalled()
expect(answer).toHaveBeenCalledWith(interaction(), {
answers: [
{ id: 'profile', selected: ['研究潜力型'] },
{ id: 'detail', selected: [] },
{ id: 'signals', selected: [] },
],
})
})
it('keeps IME Enter inside the custom input until composition finishes', () => {
const answer = vi.fn(() => Promise.resolve())
const cancel = vi.fn(() => Promise.resolve())
render(<QuestionComposer interaction={interaction()} actions={{ answer, cancel }} />)
fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
const custom = screen.getByPlaceholderText('输入你的答案')
fireEvent.change(custom, { target: { value: '中文输入' } })
fireEvent.keyDown(custom, { key: 'Enter', isComposing: true })
expect(screen.getByText('2 / 3')).toBeTruthy()
expect(answer).not.toHaveBeenCalled()
fireEvent.keyDown(custom, { key: 'Enter', keyCode: 229 })
expect(screen.getByText('2 / 3')).toBeTruthy()
expect(answer).not.toHaveBeenCalled()
fireEvent.keyDown(custom, { key: 'Enter' })
expect(screen.getByText('3 / 3')).toBeTruthy()
})
it('opens custom input, reports missing skipped answers, and supports header navigation', () => {
const answer = vi.fn(() => Promise.resolve())
const cancel = vi.fn(() => Promise.resolve())
render(<QuestionComposer interaction={interaction()} actions={{ answer, cancel }} />)
fireEvent.click(screen.getByRole('button', { name: '其他,请填写自定义答案' }))
expect(screen.getByPlaceholderText('输入你的答案')).toBeTruthy()
fireEvent.click(screen.getByRole('radio', { name: '工程落地型' }))
const emptyCustom = screen.getByPlaceholderText('输入你的答案')
fireEvent.keyDown(emptyCustom, { key: 'Enter', shiftKey: true })
expect(screen.getByText('2 / 3')).toBeTruthy()
fireEvent.keyDown(emptyCustom, { key: 'Enter' })
expect(screen.getByText('请选择一个选项或填写自定义答案。')).toBeTruthy()
fireEvent.click(screen.getByLabelText('下一题'))
fireEvent.click(screen.getByRole('checkbox', { name: '产品判断' }))
fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(screen.getByText('请先完成这道问题。')).toBeTruthy()
expect(screen.getByText('2 / 3')).toBeTruthy()
fireEvent.click(screen.getByLabelText('上一题'))
expect(screen.getByText('1 / 3')).toBeTruthy()
expect(answer).not.toHaveBeenCalled()
})
it('surfaces explicit cancellation rejection', async () => {
const answer = vi.fn(() => Promise.resolve())
const cancel = vi.fn(() => Promise.reject('取消请求失败'))
render(<QuestionComposer interaction={interaction()} actions={{ answer, cancel }} />)
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
expect(await screen.findByText('取消请求失败')).toBeTruthy()
expect((screen.getByRole('button', { name: '跳过本题' }) as HTMLButtonElement).disabled).toBe(false)
cancel.mockRejectedValueOnce(new Error('第二次取消失败'))
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
expect(await screen.findByText('第二次取消失败')).toBeTruthy()
})
it('surfaces transport rejection and resets local drafts for a different rpcId', async () => {
const answer = vi.fn(() => Promise.reject(new Error('网络中断')))
const cancel = vi.fn(() => Promise.resolve())
const first = interaction('first')
const view = render(<QuestionComposer interaction={first} actions={{ answer, cancel }} />)
fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
expect(screen.getByText('2 / 3')).toBeTruthy()
view.rerender(<QuestionComposer interaction={interaction('second')} actions={{ answer, cancel }} />)
expect(screen.getByRole('radio', { name: /研究潜力型/ }).getAttribute('aria-checked')).toBe('false')
fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
const custom = screen.getByPlaceholderText('输入你的答案')
fireEvent.change(custom, { target: { value: 'x' } })
fireEvent.keyDown(custom, { key: 'Enter' })
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(await screen.findByText('网络中断')).toBeTruthy()
expect((screen.getByRole('button', { name: '提交' }) as HTMLButtonElement).disabled).toBe(false)
answer.mockRejectedValueOnce('字符串错误')
fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(await screen.findByText('字符串错误')).toBeTruthy()
})
})
describe('parseRecommendedLabel', () => {
it('recognizes English and Chinese suffixes without changing ordinary labels', () => {
expect(parseRecommendedLabel('Fast (Recommended)')).toEqual({ label: 'Fast', recommended: true })
expect(parseRecommendedLabel('稳妥(推荐)')).toEqual({ label: '稳妥', recommended: true })
expect(parseRecommendedLabel('稳妥 (推荐)')).toEqual({ label: '稳妥', recommended: true })
expect(parseRecommendedLabel('Plain')).toEqual({ label: 'Plain', recommended: false })
})
})
describe('parseQuestionTitle', () => {
it('removes Chinese and ASCII multi-select suffixes', () => {
expect(parseQuestionTitle('选择信号(可多选)')).toBe('选择信号')
expect(parseQuestionTitle('选择信号 (可多选)')).toBe('选择信号')
expect(parseQuestionTitle('选择信号')).toBe('选择信号')
})
})

View File

@@ -0,0 +1,43 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"jsx": "react-jsx",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
{
"path": "../web-react"
},
{
"path": "../../ui/tool-ask-user"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-question', ['lib/types/index.js', 'lib/types/invariant.js'])