feat(gui): add ask-user question composer
This commit is contained in:
20
packages/client/ui-question/README.md
Normal file
20
packages/client/ui-question/README.md
Normal 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.
|
||||
66
packages/client/ui-question/package.json
Normal file
66
packages/client/ui-question/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
308
packages/client/ui-question/src/client/QuestionComposer.tsx
Normal file
308
packages/client/ui-question/src/client/QuestionComposer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
packages/client/ui-question/src/client/index.ts
Normal file
47
packages/client/ui-question/src/client/index.ts
Normal 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}`)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
4
packages/client/ui-question/src/css-modules.d.ts
vendored
Normal file
4
packages/client/ui-question/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Readonly<Record<string, string>>
|
||||
export default classes
|
||||
}
|
||||
17
packages/client/ui-question/src/index.ts
Normal file
17
packages/client/ui-question/src/index.ts
Normal 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)
|
||||
}
|
||||
31
packages/client/ui-question/src/invariant.ts
Normal file
31
packages/client/ui-question/src/invariant.ts
Normal 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 */
|
||||
58
packages/client/ui-question/tests/browser-plugin.spec.ts
Normal file
58
packages/client/ui-question/tests/browser-plugin.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
28
packages/client/ui-question/tests/node-plugin.spec.ts
Normal file
28
packages/client/ui-question/tests/node-plugin.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
198
packages/client/ui-question/tests/question-composer.spec.tsx
Normal file
198
packages/client/ui-question/tests/question-composer.spec.tsx
Normal 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('选择信号')
|
||||
})
|
||||
})
|
||||
43
packages/client/ui-question/tsconfig.json
Normal file
43
packages/client/ui-question/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-question/tsdown.config.ts
Normal file
3
packages/client/ui-question/tsdown.config.ts
Normal 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'])
|
||||
Reference in New Issue
Block a user