Merge master into worktree/pty-review-fixes
This commit is contained in:
@@ -14,12 +14,16 @@ Introduce `dsh-user-interaction` as the provider-neutral interface package for `
|
|||||||
|
|
||||||
The model-facing request vocabulary is deliberately aligned with the product-research schema: `ask_user_question({ questions: [{ id, question, header?, options?: [{ label, description? }], multi_select? }] })`. `id` is supplied per question and echoed in the result so a batch can be routed without relying on question text. `label` is both user-facing display text and the selected value returned to the model; there is no separate `value`, no `recommended`, no `allow_custom`, and no `desc` alias.
|
The model-facing request vocabulary is deliberately aligned with the product-research schema: `ask_user_question({ questions: [{ id, question, header?, options?: [{ label, description? }], multi_select? }] })`. `id` is supplied per question and echoed in the result so a batch can be routed without relying on question text. `label` is both user-facing display text and the selected value returned to the model; there is no separate `value`, no `recommended`, no `allow_custom`, and no `desc` alias.
|
||||||
|
|
||||||
Providers return `{ answers: [{ id, selected, custom? }] }`. `selected` is always an array of selected option labels, so single-select and `multi_select` answers share one result shape. `custom` carries a free-text "Other" answer; optionless questions collect `custom` directly. When `custom` is present, it overrides any selected choices and `selected` is empty.
|
Providers return `{ answers: [{ id, selected, custom? }] }`. `selected` is always an array of selected option labels, so single-select and `multi_select` answers share one result shape. `custom` carries a free-text "Other" answer; optionless questions collect `custom` directly. When `custom` is present, it overrides any selected choices and `selected` is empty. A provider that supports partial completion represents a deliberately skipped item with the existing `{ id, selected: [] }` shape, preserving the other answers without extending the tool result vocabulary.
|
||||||
|
|
||||||
`UserInteractionError` extends `HarnessError`, so failures such as `NO_PROVIDER`, `ASK_ABORTED`, ACP cancellation, or missing session routing survive `ctx.tools.execute()` as machine-routable `{ name, code }` tool errors. This matches the structured-error taxonomy and lets the model or a wrapping plugin distinguish "user cancelled" from a generic thrown exception.
|
`UserInteractionError` extends `HarnessError`, so failures such as `NO_PROVIDER`, `ASK_ABORTED`, ACP cancellation, or missing session routing survive `ctx.tools.execute()` as machine-routable `{ name, code }` tool errors. This matches the structured-error taxonomy and lets the model or a wrapping plugin distinguish "user cancelled" from a generic thrown exception.
|
||||||
|
|
||||||
## UI mappings
|
## UI mappings
|
||||||
|
|
||||||
|
`dsh web` mounts `dsh-client-ui-question`, whose host half opts the Web product into the model-facing tool and whose browser half registers a `question` entry in the conversation-owned keyed composer slot. `createApiProxy` implements the Web provider with a process-memory pending table keyed by a host-minted rpcId. It registers the wait before broadcasting `question/requested`, replays the same id on every mux reopen, validates the session and complete answer batch before claiming it, and broadcasts `question/resolved` after answer, cancellation, abort, or disposal. Claiming deletes the entry synchronously, so the first valid response wins and duplicate or late responses return `not-pending`.
|
||||||
|
|
||||||
|
The Web composer shows one question at a time while retaining every request in the session object layer. It supports single-select, multi-select, optionless or explicit custom answers, description text, and a visual recommendation badge without selecting the recommendation automatically. Single-select choices advance to the next item immediately, and Enter submits when every item is answered or explicitly skipped; Enter during IME composition only confirms the input candidate. The footer skips only the current item and preserves earlier drafts; the close control rejects the whole tool call with `ASK_CANCELLED`. The normal composer returns only after the host's resolved frame removes the pending item.
|
||||||
|
|
||||||
`dsh-tui` renders each question as a keyboard overlay, shows option descriptions, supports single- and multi-select choices plus free-form custom answers, and rejects pending questions on abort, provider disposal, or terminal shutdown. Batched and simultaneous requests are queued so one overlay owns keyboard focus at a time.
|
`dsh-tui` renders each question as a keyboard overlay, shows option descriptions, supports single- and multi-select choices plus free-form custom answers, and rejects pending questions on abort, provider disposal, or terminal shutdown. Batched and simultaneous requests are queued so one overlay owns keyboard focus at a time.
|
||||||
|
|
||||||
`dsh-acp` provides the same seam for ACP sessions. It resolves the calling `Agent` through `ownedRecord`, requiring the forward session-map record at `agent.session.id` to own that exact agent object, and calls ACP `unstable_createElicitation` with a session-scoped form for each question. Single-select options become a `choice` string enum; `multi_select` options become a `choice` array enum; optionless questions use a required `custom` text field. If the client returns both `choice` and non-empty `custom`, the custom answer wins. ACP `decline`/`cancel`, a missing answer, a missing session, and a client without elicitation support all become structured `UserInteractionError`s.
|
`dsh-acp` provides the same seam for ACP sessions. It resolves the calling `Agent` through `ownedRecord`, requiring the forward session-map record at `agent.session.id` to own that exact agent object, and calls ACP `unstable_createElicitation` with a session-scoped form for each question. Single-select options become a `choice` string enum; `multi_select` options become a `choice` array enum; optionless questions use a required `custom` text field. If the client returns both `choice` and non-empty `custom`, the custom answer wins. ACP `decline`/`cancel`, a missing answer, a missing session, and a client without elicitation support all become structured `UserInteractionError`s.
|
||||||
@@ -42,8 +46,8 @@ ACP elicitation is currently marked unstable in the SDK. The fallback is still s
|
|||||||
|
|
||||||
The feature gives the model a powerful pause primitive, so prompt guidance matters. The tool description tells the model to ask concise questions and use options when possible. Product policy can later wrap `tools/execute` to restrict when the tool is allowed, but the loop should not special-case it.
|
The feature gives the model a powerful pause primitive, so prompt guidance matters. The tool description tells the model to ask concise questions and use options when possible. Product policy can later wrap `tools/execute` to restrict when the tool is allowed, but the loop should not special-case it.
|
||||||
|
|
||||||
`dsh-user-interaction` and `dsh-tool-ask-user` both live in `packages/ui` because they form one product-facing human-interaction capability. `agent-core` does not load either the tool or a provider. `dsh-tui-demo` opts into the seam, TUI provider, and model-facing tool. `acp-agent` keeps only the `userInteraction` seam/provider by default: ACP elicitation support is still client-dependent, so an ACP leaf must opt into the model-facing tool deliberately once its client can complete elicitation requests.
|
`dsh-user-interaction` and `dsh-tool-ask-user` both live in `packages/ui` because they form one product-facing human-interaction capability. `agent-core` does not load either the tool or a provider. `dsh-tui-demo` opts into the seam, TUI provider, and model-facing tool. `dsh web` boots the seam/provider in the host runtime and exposes the tool through the selected Web question plugin. `acp-agent` keeps only the `userInteraction` seam/provider by default: ACP elicitation support is still client-dependent, so an ACP leaf must opt into the model-facing tool deliberately once its client can complete elicitation requests.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, empty-question rejection, structured tool errors through `ctx.tools.execute()`, batched answers, multi-select answers, custom answers, and the model schema including the removal of `value`, `recommended`, `allow_custom`, and `desc`. TUI tests cover option descriptions, queued requests, shutdown/abort cleanup, optionless free-form input, invalid choices, duplicate multi-select selections, and batched question flows. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify selected-option, custom-overrides-choice, multi-select, and optionless free-form elicitation paths continue the agent loop.
|
Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, empty-question rejection, structured tool errors through `ctx.tools.execute()`, batched answers, multi-select answers, custom answers, explicit per-item skips, and the model schema including the removal of `value`, `recommended`, `allow_custom`, and `desc`. TUI tests cover option descriptions, queued requests, shutdown/abort cleanup, optionless free-form input, invalid choices, duplicate multi-select selections, and batched question flows. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify selected-option, custom-overrides-choice, multi-select, and optionless free-form elicitation paths continue the agent loop. Web tests pin stable-id replay, response validation, first-wins settlement, duplicate and late responses, whole-request cancellation versus owner abort, single-select advance, IME-safe Enter submission, per-item skip preservation, composer takeover, structured batch submission, and restoration of the normal composer.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins
|
// Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins
|
||||||
// registry surface + __DSH_BOOT__ injection + built shell dist in a real
|
// registry surface + __DSH_BOOT__ injection + built shell dist in a real
|
||||||
// chromium. First describe: manifest injection + static serving. Second
|
// chromium. First describe: manifest injection + static serving. Second
|
||||||
// describe: the settled success pass — seven REAL tsdown bundles (the
|
// describe: the settled success pass — all nine REAL tsdown bundles load
|
||||||
// infrastructure four + layout/sidebar/conversation) load through the DI
|
// through the DI chain in ?fixture mode, the three-column frame appears in
|
||||||
// chain in ?fixture mode and the three-column frame appears in one flip. The
|
// one flip, and the resident question completes through the real UI stack.
|
||||||
// full conversation round lands in smoke-real under the W5 real-host standard.
|
// The full model round lands in smoke-real under the W5 real-host standard.
|
||||||
import { existsSync } from 'node:fs'
|
import { existsSync } from 'node:fs'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import type { Browser, Page } from 'playwright'
|
import type { Browser, Page } from 'playwright'
|
||||||
@@ -17,7 +17,7 @@ import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './suppo
|
|||||||
const bundlePath = (dir: string): string =>
|
const bundlePath = (dir: string): string =>
|
||||||
fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url))
|
fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url))
|
||||||
|
|
||||||
/** id ↔ bundle table for the success pass (immediately four + layout/sidebar). */
|
/** id ↔ bundle table for the success pass (the complete Web UI assembly). */
|
||||||
const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [
|
const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [
|
||||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true },
|
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true },
|
||||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||||
@@ -26,6 +26,8 @@ const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: b
|
|||||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||||
|
{ id: '@deepseek-ai/dsh-client-ui-question', dir: 'ui-question', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||||
|
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Manifest served by the fake registry: one live bundle row, one missing row. */
|
/** Manifest served by the fake registry: one live bundle row, one missing row. */
|
||||||
@@ -84,7 +86,7 @@ describe('web boot chain (keyless, real carrier)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('web boot chain success pass (keyless, seven real bundles, ?fixture)', () => {
|
describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', () => {
|
||||||
const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir)))
|
const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir)))
|
||||||
let server: Awaited<ReturnType<typeof startWebServer>>
|
let server: Awaited<ReturnType<typeof startWebServer>>
|
||||||
let browser: Browser
|
let browser: Browser
|
||||||
@@ -216,6 +218,43 @@ describe('web boot chain success pass (keyless, seven real bundles, ?fixture)',
|
|||||||
expect(await external.getAttribute('rel')).toBe('noopener noreferrer')
|
expect(await external.getAttribute('rel')).toBe('noopener noreferrer')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders and completes the resident question through the composer slot', async () => {
|
||||||
|
onTestFailed(() => saveFailureShot(page, 'smoke-question-composer'))
|
||||||
|
await page.getByText('fixture', { exact: true }).click()
|
||||||
|
await page.locator('[role="treeitem"]').nth(1).click()
|
||||||
|
const composer = page.locator('[data-question-rpc-id]')
|
||||||
|
await composer.waitFor({ timeout: 15_000 })
|
||||||
|
expect({
|
||||||
|
question: await composer.getByRole('heading').innerText(),
|
||||||
|
progress: await composer.getByText('1 / 3', { exact: true }).innerText(),
|
||||||
|
options: await composer.getByRole('radio').allTextContents(),
|
||||||
|
custom: await composer.getByRole('button', { name: '其他,请填写自定义答案' }).innerText(),
|
||||||
|
}).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"custom": "其他,请填写自定义答案",
|
||||||
|
"options": [
|
||||||
|
"1工程落地型推荐更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。",
|
||||||
|
"2研究潜力型更看重 Agent 理解、训练评测思路和长期成长空间。",
|
||||||
|
"3均衡型同时要求工程能力和 Agent 认知,但可能筛选门槛更高。",
|
||||||
|
],
|
||||||
|
"progress": "1 / 3",
|
||||||
|
"question": "你现在更想招哪类 Agent/Harness 候选人?",
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
await composer.getByRole('radio', { name: '工程落地型' }).click()
|
||||||
|
await composer.getByText('2 / 3', { exact: true }).waitFor()
|
||||||
|
await composer.getByRole('button', { name: '跳过本题', exact: true }).click()
|
||||||
|
await composer.getByRole('checkbox', { name: '系统设计' }).click()
|
||||||
|
await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).click()
|
||||||
|
await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).press('Enter')
|
||||||
|
|
||||||
|
await composer.waitFor({ state: 'detached' })
|
||||||
|
const restoredInput = page.locator('textarea[placeholder]')
|
||||||
|
await restoredInput.waitFor()
|
||||||
|
expect(await restoredInput.getAttribute('placeholder')).toBe('回复生成中,可停止后再输入')
|
||||||
|
})
|
||||||
|
|
||||||
it('stayed clean: no page errors across the whole load chain', () => {
|
it('stayed clean: no page errors across the whole load chain', () => {
|
||||||
expect(pageErrors).toEqual([])
|
expect(pageErrors).toEqual([])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -95,10 +95,10 @@ async function detailsTrack(page: Page): Promise<number> {
|
|||||||
return Number(cols.split(' ').pop()!.replace('px', ''))
|
return Number(cols.split(' ').pop()!.replace('px', ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Readiness gate: `dsh web` serves ALL eight manifest plugins; until every UI
|
// Readiness gate: `dsh web` serves ALL nine manifest plugins; until every UI
|
||||||
// plugin's client bundle exists and exports apply, the loader fail-louds and
|
// plugin's client bundle exists and exports apply, the loader fail-louds and
|
||||||
// the frame never appears.
|
// the frame never appears.
|
||||||
const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-trajectory']
|
const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-question', 'ui-trajectory']
|
||||||
const notReady = UI_PLUGIN_DIRS.filter((dir) => {
|
const notReady = UI_PLUGIN_DIRS.filter((dir) => {
|
||||||
const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js')
|
const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js')
|
||||||
return !existsSync(bundle) || !readFileSync(bundle, 'utf8').includes('exports.apply')
|
return !existsSync(bundle) || !readFileSync(bundle, 'utf8').includes('exports.apply')
|
||||||
|
|||||||
@@ -1878,6 +1878,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
|||||||
- `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))
|
- `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))
|
||||||
- `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
|
- `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
|
||||||
- `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts))
|
- `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts))
|
||||||
|
- `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts))
|
||||||
- `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
|
- `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
|
||||||
- `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))
|
- `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))
|
||||||
- `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts))
|
- `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts))
|
||||||
|
|||||||
@@ -58,14 +58,14 @@ interface AskUserQuestionRequest {
|
|||||||
|
|
||||||
## Answer
|
## Answer
|
||||||
|
|
||||||
Providers return one answer per answered question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. When `custom` is present, `selected` is empty; custom text is an answer override, not a supplement to selected choices.
|
Providers return one answer item per question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. When `custom` is present, `selected` is empty; custom text is an answer override, not a supplement to selected choices. A UI may also use an item with empty `selected` and no `custom` to preserve a skipped question in an otherwise completed batch.
|
||||||
|
|
||||||
```ts type-equiv
|
```ts type-equiv
|
||||||
/** Answer to one question. */
|
/** Answer to one question. */
|
||||||
interface AskUserQuestionAnswerItem {
|
interface AskUserQuestionAnswerItem {
|
||||||
/** The answered question id. */
|
/** The answered question id. */
|
||||||
id: string
|
id: string
|
||||||
/** Selected option labels. Empty when the answer is purely custom text. */
|
/** Selected option labels. Empty for custom or unanswered choices. */
|
||||||
selected: string[]
|
selected: string[]
|
||||||
/** Optional free-text "Other" answer. */
|
/** Optional free-text "Other" answer. */
|
||||||
custom?: string
|
custom?: string
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ flowchart TD
|
|||||||
pkg_client_ui_conversation["client-ui-conversation"]
|
pkg_client_ui_conversation["client-ui-conversation"]
|
||||||
pkg_client_ui_layout["client-ui-layout"]
|
pkg_client_ui_layout["client-ui-layout"]
|
||||||
pkg_client_ui_primitives["client-ui-primitives"]
|
pkg_client_ui_primitives["client-ui-primitives"]
|
||||||
|
pkg_client_ui_question["client-ui-question"]
|
||||||
pkg_client_ui_sidebar["client-ui-sidebar"]
|
pkg_client_ui_sidebar["client-ui-sidebar"]
|
||||||
pkg_client_ui_slots["client-ui-slots"]
|
pkg_client_ui_slots["client-ui-slots"]
|
||||||
pkg_client_ui_theme["client-ui-theme"]
|
pkg_client_ui_theme["client-ui-theme"]
|
||||||
@@ -216,6 +217,7 @@ flowchart TD
|
|||||||
pkg_client_ui_conversation --> pkg_invariants
|
pkg_client_ui_conversation --> pkg_invariants
|
||||||
pkg_client_ui_layout --> pkg_invariants
|
pkg_client_ui_layout --> pkg_invariants
|
||||||
pkg_client_ui_primitives --> pkg_invariants
|
pkg_client_ui_primitives --> pkg_invariants
|
||||||
|
pkg_client_ui_question --> pkg_invariants
|
||||||
pkg_client_ui_sidebar --> pkg_invariants
|
pkg_client_ui_sidebar --> pkg_invariants
|
||||||
pkg_client_ui_slots --> pkg_invariants
|
pkg_client_ui_slots --> pkg_invariants
|
||||||
pkg_client_ui_theme --> pkg_invariants
|
pkg_client_ui_theme --> pkg_invariants
|
||||||
@@ -769,6 +771,7 @@ flowchart TD
|
|||||||
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`invariants`](../packages/support/invariants) |
|
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`invariants`](../packages/support/invariants) |
|
||||||
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`invariants`](../packages/support/invariants) |
|
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`invariants`](../packages/support/invariants) |
|
||||||
| [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) |
|
| [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) |
|
||||||
|
| [`client-ui-question`](../packages/client/ui-question) | `client` | [`invariants`](../packages/support/invariants) |
|
||||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`invariants`](../packages/support/invariants) |
|
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`invariants`](../packages/support/invariants) |
|
||||||
| [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) |
|
| [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) |
|
||||||
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`invariants`](../packages/support/invariants) |
|
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`invariants`](../packages/support/invariants) |
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
// RpcRequest<P> and returns RpcResponse<T> (echoing the rpcId); streams yield RpcRequest<frame>
|
// RpcRequest<P> and returns RpcResponse<T> (echoing the rpcId); streams yield RpcRequest<frame>
|
||||||
// (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse
|
// (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse
|
||||||
// and returns RpcReceipt. fx-alpha carries a hand-built history script (60 turns, pageable);
|
// and returns RpcReceipt. fx-alpha carries a hand-built history script (60 turns, pageable);
|
||||||
// prompt triggers a chunked streaming replay; cancel stops the replay; one resident pending
|
// prompt triggers a chunked streaming replay; cancel stops the replay; resident pending
|
||||||
// approval (placeholder-card material, subscribed-baseline-replay semantics: stable rpcId reuse).
|
// approval/question requests exercise replay and composer takeover with stable rpcIds.
|
||||||
|
|
||||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||||
@@ -281,6 +281,41 @@ export function createFixtureApi(): ApiProxy {
|
|||||||
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
|
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
|
||||||
/** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
|
/** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
|
||||||
const pendingApprovalRpcId = mint()
|
const pendingApprovalRpcId = mint()
|
||||||
|
const pendingQuestionRpcId = mint()
|
||||||
|
let questionPending = true
|
||||||
|
const fixtureQuestions: Extract<MuxFrame, { type: 'question/requested' }>['questions'] = [
|
||||||
|
{
|
||||||
|
id: 'harness-profile',
|
||||||
|
header: '偏好',
|
||||||
|
question: '你现在更想招哪类 Agent/Harness 候选人?',
|
||||||
|
options: [
|
||||||
|
{ label: '工程落地型 (Recommended)', description: '更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。' },
|
||||||
|
{ label: '研究潜力型', description: '更看重 Agent 理解、训练评测思路和长期成长空间。' },
|
||||||
|
{ label: '均衡型', description: '同时要求工程能力和 Agent 认知,但可能筛选门槛更高。' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'work-mode',
|
||||||
|
header: '方式',
|
||||||
|
question: '你希望候选人优先展示哪种工作方式?',
|
||||||
|
options: [
|
||||||
|
{ label: '先做小型原型 (Recommended)', description: '用可运行结果尽快验证关键假设。' },
|
||||||
|
{ label: '先写完整设计', description: '先收敛边界、协议和风险,再开始实现。' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'signals',
|
||||||
|
header: '信号',
|
||||||
|
question: '哪些面试信号最重要?',
|
||||||
|
detail: '按当前招聘目标选择;跳过则视为不设偏好。',
|
||||||
|
multiSelect: true,
|
||||||
|
options: [
|
||||||
|
{ label: '系统设计' },
|
||||||
|
{ label: '代码质量' },
|
||||||
|
{ label: 'Agent 产品判断' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const muxConns = new Set<StreamConn<MuxFrame>>()
|
const muxConns = new Set<StreamConn<MuxFrame>>()
|
||||||
const hostConns = new Set<StreamConn<HostFrame>>()
|
const hostConns = new Set<StreamConn<HostFrame>>()
|
||||||
@@ -467,7 +502,7 @@ export function createFixtureApi(): ApiProxy {
|
|||||||
muxConns.add(conn)
|
muxConns.add(conn)
|
||||||
const breakNow = (): void => { conn.breakNow() }
|
const breakNow = (): void => { conn.breakNow() }
|
||||||
streamBreakers.add(breakNow)
|
streamBreakers.add(breakNow)
|
||||||
// Open baseline: subscribed for attached (running) sessions + pending approval replay (stable rpcId).
|
// Open baseline: subscribed sessions + pending interactions replayed with stable rpcIds.
|
||||||
for (const s of sessions) {
|
for (const s of sessions) {
|
||||||
if (!s.running) continue
|
if (!s.running) continue
|
||||||
conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: (logs.get(s.sessionId)?.length ?? 0) - 1 } })
|
conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: (logs.get(s.sessionId)?.length ?? 0) - 1 } })
|
||||||
@@ -480,6 +515,14 @@ export function createFixtureApi(): ApiProxy {
|
|||||||
toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)',
|
toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
if (questionPending) {
|
||||||
|
conn.push({
|
||||||
|
rpcId: pendingQuestionRpcId,
|
||||||
|
payload: {
|
||||||
|
type: 'question/requested', sessionId: sid('fx-alpha'), questions: fixtureQuestions,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
yield* conn.drain(signal)
|
yield* conn.drain(signal)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -509,9 +552,16 @@ export function createFixtureApi(): ApiProxy {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
respond(message: ClientResponse): Promise<RpcReceipt> {
|
respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||||
// The v1 UI never answers (PendingCard is visible but not answerable); implemented for type completeness, always not-pending.
|
if (!questionPending || message.rpcId !== pendingQuestionRpcId) {
|
||||||
void message
|
return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
||||||
return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
}
|
||||||
|
questionPending = false
|
||||||
|
emitMux({
|
||||||
|
type: 'question/resolved', sessionId: sid('fx-alpha'),
|
||||||
|
questionRpcId: pendingQuestionRpcId,
|
||||||
|
outcome: message.result.ok ? 'answered' : 'cancelled',
|
||||||
|
})
|
||||||
|
return Promise.resolve({ accepted: true })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,14 +148,14 @@ describe('createFixtureApi', () => {
|
|||||||
expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn
|
expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn
|
||||||
})
|
})
|
||||||
|
|
||||||
it('mux open replays the baseline: subscribed for running sessions + the resident approval with a stable rpcId', async () => {
|
it('mux open replays subscribed sessions and resident interactions with stable rpcIds', async () => {
|
||||||
const api = createFixtureApi()
|
const api = createFixtureApi()
|
||||||
const openOnce = async (): Promise<RpcRequest<MuxFrame>[]> => {
|
const openOnce = async (): Promise<RpcRequest<MuxFrame>[]> => {
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
const envelopes: RpcRequest<MuxFrame>[] = []
|
const envelopes: RpcRequest<MuxFrame>[] = []
|
||||||
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
||||||
envelopes.push(envelope)
|
envelopes.push(envelope)
|
||||||
if (envelopes.length >= 2) abort.abort()
|
if (envelopes.length >= 3) abort.abort()
|
||||||
}
|
}
|
||||||
return envelopes
|
return envelopes
|
||||||
}
|
}
|
||||||
@@ -165,6 +165,8 @@ describe('createFixtureApi', () => {
|
|||||||
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
|
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
|
||||||
expect(first[1]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
expect(first[1]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||||
expect(second[1]?.rpcId).toBe(first[1]?.rpcId) // stable rpcId across replays (host replay semantics)
|
expect(second[1]?.rpcId).toBe(first[1]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||||
|
expect(first[2]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
||||||
|
expect(second[2]?.rpcId).toBe(first[2]?.rpcId)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
|
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
|
||||||
@@ -217,9 +219,38 @@ describe('createFixtureApi', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('respond is a typed stub: always not-pending', async () => {
|
it('respond resolves the resident question once and rejects duplicate or unrelated ids', async () => {
|
||||||
const api = createFixtureApi()
|
const api = createFixtureApi()
|
||||||
expect(await api.respond({ type: 'client-response', rpcId: RpcId('x'), result: { ok: true, value: {} } })).toEqual({ accepted: false, reason: 'not-pending' })
|
expect(await api.respond({ type: 'client-response', rpcId: RpcId('x'), result: { ok: true, value: {} } })).toEqual({ accepted: false, reason: 'not-pending' })
|
||||||
|
const abort = new AbortController()
|
||||||
|
let question: RpcRequest<MuxFrame> | undefined
|
||||||
|
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
||||||
|
if (envelope.payload.type !== 'question/requested') continue
|
||||||
|
question = envelope
|
||||||
|
abort.abort()
|
||||||
|
}
|
||||||
|
if (question === undefined) throw new Error('fixture question missing')
|
||||||
|
const response = { type: 'client-response' as const, rpcId: question.rpcId, result: { ok: true as const, value: {} } }
|
||||||
|
expect(await api.respond(response)).toEqual({ accepted: true })
|
||||||
|
expect(await api.respond(response)).toEqual({ accepted: false, reason: 'not-pending' })
|
||||||
|
|
||||||
|
const replayAbort = new AbortController()
|
||||||
|
const replayed = await collect(api.events.mux(req({}), replayAbort.signal), replayAbort, frames => frames.length === 2)
|
||||||
|
expect(replayed.every(frame => frame.type !== 'question/requested')).toBe(true)
|
||||||
|
|
||||||
|
const cancelledApi = createFixtureApi()
|
||||||
|
const cancelAbort = new AbortController()
|
||||||
|
let cancelQuestion: RpcRequest<MuxFrame> | undefined
|
||||||
|
for await (const envelope of cancelledApi.events.mux(req({}), cancelAbort.signal)) {
|
||||||
|
if (envelope.payload.type !== 'question/requested') continue
|
||||||
|
cancelQuestion = envelope
|
||||||
|
cancelAbort.abort()
|
||||||
|
}
|
||||||
|
if (cancelQuestion === undefined) throw new Error('fixture cancellation question missing')
|
||||||
|
expect(await cancelledApi.respond({
|
||||||
|
type: 'client-response', rpcId: cancelQuestion.rpcId,
|
||||||
|
result: { ok: false, error: { code: 'cancelled', message: 'skip', details: {} } },
|
||||||
|
})).toEqual({ accepted: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('describe answers the fixture identity', async () => {
|
it('describe answers the fixture identity', async () => {
|
||||||
|
|||||||
@@ -26,4 +26,4 @@ None; this package neither assembles nor sends a provider request.
|
|||||||
- **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred.
|
- **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred.
|
||||||
- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
|
- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
|
||||||
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
|
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
|
||||||
- **Approval/question cards are display-only placeholders** — web-side answering (composer takeover panel) is the P-II approvals project.
|
- **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project.
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ export type {
|
|||||||
} from './contract/views.ts'
|
} from './contract/views.ts'
|
||||||
export type { ToolCallBlock } from './contract/tool-call-model.ts'
|
export type { ToolCallBlock } from './contract/tool-call-model.ts'
|
||||||
export type {
|
export type {
|
||||||
ChatStore, ChatViewInjected, ChatViewSlotProps, ConversationInjected, ConversationSlotProps,
|
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ConversationInjected,
|
||||||
ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
|
ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
|
||||||
EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps,
|
EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps,
|
||||||
} from './contract/slots.ts'
|
} from './contract/slots.ts'
|
||||||
// Export discipline: packages/client/AGENTS.md.
|
// Export discipline: packages/client/AGENTS.md.
|
||||||
|
|||||||
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.
|
||||||
67
packages/client/ui-question/package.json
Normal file
67
packages/client/ui-question/package.json
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"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-client-ui-slots": "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,348 @@
|
|||||||
|
.frame {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px 24px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
/* Composer seat sits in a fixed-height conversation column (overflow
|
||||||
|
hidden): cap the card against the viewport and scroll the option list
|
||||||
|
so header and footer actions stay reachable on long batches. */
|
||||||
|
max-height: min(60vh, 520px);
|
||||||
|
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;
|
||||||
|
flex-shrink: 0;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: var(--dsw-alias-label-tertiary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
/* The scrollable region of the capped card (ChatView list pattern). */
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
flex-shrink: 0;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
297
packages/client/ui-question/src/client/QuestionComposer.tsx
Normal file
297
packages/client/ui-question/src/client/QuestionComposer.tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import { useMemo, useState, type KeyboardEvent } from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import {
|
||||||
|
Button, IconCheckOutline16, IconChevronLeftOutline14, IconChevronRightOutline14,
|
||||||
|
IconCloseOutline16, IconEditOutline16,
|
||||||
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
|
import { PendingQuestion, type QuestionAnswer, type QuestionComposerProps } from './contract/slots.ts'
|
||||||
|
import css from './QuestionComposer.module.css'
|
||||||
|
|
||||||
|
interface DraftAnswer {
|
||||||
|
selected: string[]
|
||||||
|
custom: string
|
||||||
|
customOpen: boolean
|
||||||
|
skipped: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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; the carrier key keys local drafts, so a
|
||||||
|
* same-request replay (same key, new carrier object) preserves them.
|
||||||
|
* @param props - the selector-matched pending question carrier plus the framework standard kit.
|
||||||
|
* @returns The question flow for this request.
|
||||||
|
*/
|
||||||
|
export function QuestionComposer(props: QuestionComposerProps) {
|
||||||
|
// Domain-face mint rides the carrier's stable identity (never minted in a
|
||||||
|
// select/render dispatch — per-dispatch minting would churn memo identity).
|
||||||
|
const question = useMemo(() => new PendingQuestion(props.matched), [props.matched])
|
||||||
|
return <QuestionFlow key={question.key} pending={question} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||||
|
const questions = pending.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 pending.cancel().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: QuestionAnswer = {
|
||||||
|
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 pending.answer(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-key={pending.key}>
|
||||||
|
<section className={css.card} aria-labelledby={`question-${pending.key}-${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-${pending.key}-${String(index)}`}>
|
||||||
|
<span>{question.multiSelect === true
|
||||||
|
? parseQuestionTitle(question.question)
|
||||||
|
: question.question}</span>
|
||||||
|
{question.multiSelect === true && <span className={css.multiSelectHint}>可多选</span>}
|
||||||
|
</h2>
|
||||||
|
{question.detail !== undefined && <p className={css.detail}>{question.detail}</p>}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
packages/client/ui-question/src/client/contract/slots.ts
Normal file
77
packages/client/ui-question/src/client/contract/slots.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Question-composer slot contract: the registrant-side props composition for
|
||||||
|
* the conversation-owned `conversation.composer` slot, plus the question
|
||||||
|
* domain face over the runtime's carrier object. The carrier (PendingWait)
|
||||||
|
* owns envelope transport only; the question protocol — answer value shape,
|
||||||
|
* cancelled error encoding, receipt checks — lives HERE, with the package
|
||||||
|
* that consumes it.
|
||||||
|
*/
|
||||||
|
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||||
|
// Also pulls ui-conversation's SlotMap merge (the 'conversation.composer'
|
||||||
|
// entry) into every program that sees this contract, so PropsRuntime resolves.
|
||||||
|
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||||
|
import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
|
import type { QuestionResponsePayload } from '@deepseek-ai/dsh-client-connection/client'
|
||||||
|
|
||||||
|
/** The pending question carrier the owner dispatches into the composer slot. */
|
||||||
|
export type QuestionWait = PendingWait<'question'>
|
||||||
|
|
||||||
|
/** One structured answer batch covering every question of the request. */
|
||||||
|
export type QuestionAnswer = QuestionResponsePayload['answer']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Question domain face over the carrier: render identity and questions
|
||||||
|
* transparently forwarded; answer/cancel own the wire encoding (the ok value
|
||||||
|
* shape and the cancelled error) and turn a rejected carrier receipt into a
|
||||||
|
* thrown error. Components mint one per carrier via useMemo (never inside a
|
||||||
|
* select — a per-dispatch mint would churn identity and break memoization).
|
||||||
|
*/
|
||||||
|
export class PendingQuestion {
|
||||||
|
/**
|
||||||
|
* @param wait - the runtime carrier for one pending question request.
|
||||||
|
*/
|
||||||
|
constructor(private readonly wait: QuestionWait) {}
|
||||||
|
|
||||||
|
/** Opaque render identity (React key / draft remount axis), forwarded from the carrier. */
|
||||||
|
get key(): string {
|
||||||
|
return this.wait.key
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The request's question list, forwarded from the carrier payload. */
|
||||||
|
get questions(): QuestionWait['payload']['questions'] {
|
||||||
|
return this.wait.payload.questions
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver the whole answer batch; a rejected carrier receipt throws.
|
||||||
|
* @param answer - complete structured answer batch.
|
||||||
|
*/
|
||||||
|
async answer(answer: QuestionAnswer): Promise<void> {
|
||||||
|
const receipt = await this.wait.respond({
|
||||||
|
ok: true, value: { sessionId: this.wait.sessionId, answer },
|
||||||
|
})
|
||||||
|
if (!receipt.accepted) {
|
||||||
|
throw new Error(`question response rejected: ${receipt.reason}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject the whole wait (the host resolves the tool call as cancelled); a rejected receipt throws. */
|
||||||
|
async cancel(): Promise<void> {
|
||||||
|
const receipt = await this.wait.respond({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
|
||||||
|
})
|
||||||
|
if (!receipt.accepted) {
|
||||||
|
throw new Error(`question cancellation rejected: ${receipt.reason}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full component props: the framework runtime share (chain currency +
|
||||||
|
* session/global standard kit) plus the chain `matched` share — the entry's
|
||||||
|
* selector result, already narrowed to the question carrier. No injected
|
||||||
|
* share: the carrier plus the domain face above carry the whole behavior
|
||||||
|
* surface.
|
||||||
|
*/
|
||||||
|
export type QuestionComposerProps = PropsRuntime<'conversation.composer'> & { matched: QuestionWait }
|
||||||
36
packages/client/ui-question/src/client/index.ts
Normal file
36
packages/client/ui-question/src/client/index.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Web question plugin, browser half: QuestionComposer registered as a
|
||||||
|
* selector-routed entry of the conversation-declared composer chain. Pure
|
||||||
|
* consumer — the selector narrows the owner's currency to the question
|
||||||
|
* carrier (matched prop), and the whole behavior surface rides the carrier
|
||||||
|
* (domain encoding in contract/slots.ts PendingQuestion); no inject face, no
|
||||||
|
* service dependency beyond slots. Export discipline: packages/client/AGENTS.md.
|
||||||
|
*/
|
||||||
|
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
|
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||||
|
import type { QuestionWait } from './contract/slots.ts'
|
||||||
|
import { QuestionComposer } from './QuestionComposer.tsx'
|
||||||
|
|
||||||
|
export { PendingQuestion } from './contract/slots.ts'
|
||||||
|
export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
|
||||||
|
|
||||||
|
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
|
||||||
|
export const inject = ['slots']
|
||||||
|
|
||||||
|
/** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */
|
||||||
|
function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null {
|
||||||
|
return interactions.find((i): i is QuestionWait => i.kind === 'question') ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client plugin body: register the question composer into the composer chain.
|
||||||
|
* Zero business face — data and verbs both live on the matched carrier.
|
||||||
|
* @param ctx - client root context.
|
||||||
|
*/
|
||||||
|
export function apply(ctx: ClientContext): void {
|
||||||
|
const slots = ctx.slots
|
||||||
|
ctx.effect(
|
||||||
|
() => slots.register({ name: 'conversation.composer', select: selectQuestion }, QuestionComposer),
|
||||||
|
'ui-question: composer chain registration',
|
||||||
|
)
|
||||||
|
}
|
||||||
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 */
|
||||||
64
packages/client/ui-question/tests/browser-plugin.spec.ts
Normal file
64
packages/client/ui-question/tests/browser-plugin.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* apply wiring on a real cordis Context + SlotsService: QuestionComposer
|
||||||
|
* registered as the `question` entry of the conversation-declared composer
|
||||||
|
* slot with ZERO business face (data and verbs ride the dispatched carrier),
|
||||||
|
* load-order fail-loud, and fiber-teardown unregistration. Component and
|
||||||
|
* domain-face behavior is covered props-direct in question-composer.spec.tsx;
|
||||||
|
* no renderer machinery here.
|
||||||
|
*/
|
||||||
|
import { Context } from 'cordis'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
|
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
|
||||||
|
import { apply, inject } from '../src/client/index.ts'
|
||||||
|
|
||||||
|
async function bench() {
|
||||||
|
const ctx = new Context()
|
||||||
|
await ctx.plugin(SlotsService).await()
|
||||||
|
const slots = ctx.get('slots') as SlotsService
|
||||||
|
// Stand-in for ui-conversation's conversation entry: the composer slot only
|
||||||
|
// exists while a live entry declares it in children (declaration account:
|
||||||
|
// design §2.2).
|
||||||
|
slots.register(
|
||||||
|
{ name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
|
||||||
|
() => null,
|
||||||
|
)
|
||||||
|
return { ctx, slots }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('apply', () => {
|
||||||
|
it('declares the services it binds', () => {
|
||||||
|
expect(inject).toEqual(['slots'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails loud when no live entry has declared the composer slot', async () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
await ctx.plugin(SlotsService).await()
|
||||||
|
await expect(ctx.plugin({ inject: [...inject], apply }))
|
||||||
|
.rejects.toThrow(/slot "conversation.composer" is not declared/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('registers the question entry: routing selector, no inject face', async () => {
|
||||||
|
const { ctx, slots } = await bench()
|
||||||
|
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||||
|
const entry = slots.entries('conversation.composer')[0]!
|
||||||
|
expect(entry.component).toBe(QuestionComposer)
|
||||||
|
// The whole behavior surface rides the matched carrier: no business face.
|
||||||
|
expect(entry.inject).toBeUndefined()
|
||||||
|
// The selector narrows the chain currency: question wait in → that wait; none → null.
|
||||||
|
const select = entry.select as (owner: { interactions: readonly { kind: string }[] }) => unknown
|
||||||
|
const question = { kind: 'question' }
|
||||||
|
expect(select({ interactions: [{ kind: 'approval' }, question] })).toBe(question)
|
||||||
|
expect(select({ interactions: [{ kind: 'approval' }] })).toBeNull()
|
||||||
|
expect(select({ interactions: [] })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('teardown unregisters the slot entry', async () => {
|
||||||
|
const { ctx, slots } = await bench()
|
||||||
|
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||||
|
await fiber.await()
|
||||||
|
expect(slots.entries('conversation.composer')).toHaveLength(1)
|
||||||
|
await fiber.dispose()
|
||||||
|
expect(slots.entries('conversation.composer')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
265
packages/client/ui-question/tests/question-composer.spec.tsx
Normal file
265
packages/client/ui-question/tests/question-composer.spec.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
|
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
|
import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client'
|
||||||
|
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||||
|
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||||
|
import { PendingQuestion } from '../src/client/contract/slots.ts'
|
||||||
|
import {
|
||||||
|
QuestionComposer, parseQuestionTitle, parseRecommendedLabel,
|
||||||
|
} from '../src/client/QuestionComposer.tsx'
|
||||||
|
|
||||||
|
afterEach(cleanup)
|
||||||
|
|
||||||
|
const SID = 's1' as SessionId
|
||||||
|
|
||||||
|
/** Framework standard-kit stubs: the composer consumes none of them, the
|
||||||
|
* composed props type mandates their delivery (framework hooks are plain
|
||||||
|
* stubs per the client testing discipline). */
|
||||||
|
const kit = {
|
||||||
|
sessionId: SID,
|
||||||
|
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
|
||||||
|
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUESTIONS = [
|
||||||
|
{
|
||||||
|
id: 'profile', header: '偏好', question: '选择候选人类型',
|
||||||
|
detail: '按当前空缺岗位的优先级选择。',
|
||||||
|
options: [
|
||||||
|
{ label: '工程落地型 (Recommended)', description: '优先工程交付。' },
|
||||||
|
{ label: '研究潜力型', description: '优先研究能力。' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'detail', question: '补充你的要求',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'signals', question: '选择重要信号(可多选)', multiSelect: true,
|
||||||
|
options: [{ label: '系统设计' }, { label: '代码质量' }, { label: '产品判断' }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Carrier fixture: a real PendingWait over a scripted respond carrier. */
|
||||||
|
function wait(rpcId = 'question-1', respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true }))) {
|
||||||
|
const carrier = new PendingWait(
|
||||||
|
'question', RpcId(rpcId), SID, { questions: QUESTIONS } as PendingWait<'question'>['payload'], respond)
|
||||||
|
return { carrier, respond }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The client-response envelope respond must have received for an answer batch. */
|
||||||
|
function answeredEnvelope(rpcId: string, answers: object[]) {
|
||||||
|
return {
|
||||||
|
type: 'client-response', rpcId: RpcId(rpcId),
|
||||||
|
result: { ok: true, value: { sessionId: SID, answer: { answers } } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('QuestionComposer', () => {
|
||||||
|
it('collects single, custom, and multi-select answers before one batch submit', () => {
|
||||||
|
const { carrier, respond } = wait()
|
||||||
|
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('1 / 3')).toBeTruthy()
|
||||||
|
expect(screen.getByText('推荐')).toBeTruthy()
|
||||||
|
expect(screen.getByText('工程落地型')).toBeTruthy()
|
||||||
|
expect(screen.getByText('按当前空缺岗位的优先级选择。')).toBeTruthy()
|
||||||
|
fireEvent.keyDown(screen.getByRole('radio', { name: /工程落地型/ }), { key: 'Enter' })
|
||||||
|
expect(respond).not.toHaveBeenCalled()
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
|
||||||
|
|
||||||
|
expect(screen.getByText('2 / 3')).toBeTruthy()
|
||||||
|
// detail is per-question: the second question carries none.
|
||||||
|
expect(screen.queryByText('按当前空缺岗位的优先级选择。')).toBeNull()
|
||||||
|
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' })
|
||||||
|
|
||||||
|
// The domain face encoded the whole batch into one carrier envelope.
|
||||||
|
expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
|
||||||
|
{ 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 { carrier, respond } = wait()
|
||||||
|
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||||
|
|
||||||
|
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(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
|
||||||
|
{ id: 'profile', selected: ['研究潜力型'] },
|
||||||
|
{ id: 'detail', selected: [] },
|
||||||
|
{ id: 'signals', selected: [] },
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps IME Enter inside the custom input until composition finishes', () => {
|
||||||
|
const { carrier, respond } = wait()
|
||||||
|
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||||
|
|
||||||
|
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(respond).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
fireEvent.keyDown(custom, { key: 'Enter', keyCode: 229 })
|
||||||
|
expect(screen.getByText('2 / 3')).toBeTruthy()
|
||||||
|
expect(respond).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 { carrier, respond } = wait()
|
||||||
|
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||||
|
|
||||||
|
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(respond).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces cancellation failures: rejected receipt text and raw transport reasons', async () => {
|
||||||
|
const respond = vi.fn()
|
||||||
|
.mockResolvedValueOnce({ accepted: false, reason: 'bad-response' })
|
||||||
|
.mockRejectedValueOnce(new Error('第二次取消失败'))
|
||||||
|
const { carrier } = wait('question-1', respond)
|
||||||
|
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||||
|
|
||||||
|
// Receipt rejection surfaces through the domain face's thrown message.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
|
||||||
|
expect(await screen.findByText('question cancellation rejected: bad-response')).toBeTruthy()
|
||||||
|
expect((screen.getByRole('button', { name: '跳过本题' }) as HTMLButtonElement).disabled).toBe(false)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
|
||||||
|
expect(await screen.findByText('第二次取消失败')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces transport rejection and resets local drafts for a different request', async () => {
|
||||||
|
const respond = vi.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('网络中断'))
|
||||||
|
.mockRejectedValueOnce('字符串错误')
|
||||||
|
const first = wait('first', respond)
|
||||||
|
const view = render(<QuestionComposer matched={first.carrier} interactions={[first.carrier]} {...kit} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
|
||||||
|
expect(screen.getByText('2 / 3')).toBeTruthy()
|
||||||
|
const second = wait('second', respond)
|
||||||
|
view.rerender(<QuestionComposer matched={second.carrier} interactions={[second.carrier]} {...kit} />)
|
||||||
|
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)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
||||||
|
expect(await screen.findByText('字符串错误')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('same-key carrier replacement (baseline replay) keeps drafts', () => {
|
||||||
|
const first = wait('same-id')
|
||||||
|
const view = render(<QuestionComposer matched={first.carrier} interactions={[first.carrier]} {...kit} />)
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
|
||||||
|
expect(screen.getByText('2 / 3')).toBeTruthy()
|
||||||
|
// Replay mints a NEW carrier for the same request; same key = no remount.
|
||||||
|
const replayed = wait('same-id')
|
||||||
|
view.rerender(<QuestionComposer matched={replayed.carrier} interactions={[replayed.carrier]} {...kit} />)
|
||||||
|
expect(screen.getByText('2 / 3')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('PendingQuestion domain face', () => {
|
||||||
|
it('encodes the answer batch into the ok envelope and throws on a rejected receipt', async () => {
|
||||||
|
const respond = vi.fn()
|
||||||
|
.mockResolvedValueOnce({ accepted: true })
|
||||||
|
.mockResolvedValueOnce({ accepted: false, reason: 'not-pending' })
|
||||||
|
const question = new PendingQuestion(wait('rq', respond).carrier)
|
||||||
|
const batch = { answers: [{ id: 'mode', selected: ['Fast'] }] }
|
||||||
|
await expect(question.answer(batch)).resolves.toBeUndefined()
|
||||||
|
expect(respond).toHaveBeenCalledWith(answeredEnvelope('rq', batch.answers))
|
||||||
|
await expect(question.answer(batch)).rejects.toThrow(/question response rejected: not-pending/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('encodes cancellation as the cancelled error envelope and throws on a rejected receipt', async () => {
|
||||||
|
const respond = vi.fn()
|
||||||
|
.mockResolvedValueOnce({ accepted: true })
|
||||||
|
.mockResolvedValueOnce({ accepted: false, reason: 'bad-response' })
|
||||||
|
const question = new PendingQuestion(wait('rc', respond).carrier)
|
||||||
|
await expect(question.cancel()).resolves.toBeUndefined()
|
||||||
|
expect(respond).toHaveBeenCalledWith({
|
||||||
|
type: 'client-response', rpcId: RpcId('rc'),
|
||||||
|
result: {
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await expect(question.cancel()).rejects.toThrow(/question cancellation rejected: bad-response/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forwards key and questions from the carrier', () => {
|
||||||
|
const question = new PendingQuestion(wait('rk').carrier)
|
||||||
|
expect(question.key).toBe('q:rk')
|
||||||
|
expect(question.questions).toBe(wait('rk').carrier.payload.questions)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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('选择信号')
|
||||||
|
})
|
||||||
|
})
|
||||||
36
packages/client/ui-question/tsconfig.json
Normal file
36
packages/client/ui-question/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.client.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "lib/types"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "../../../vendor/cordis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../connection"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../runtime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../ui-conversation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../ui-primitives"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../ui-slots"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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'])
|
||||||
@@ -17,6 +17,7 @@ export const askUserQuestionItemSchema = z.object({
|
|||||||
id: z.string(),
|
id: z.string(),
|
||||||
question: z.string(),
|
question: z.string(),
|
||||||
header: z.string().optional(),
|
header: z.string().optional(),
|
||||||
|
detail: z.string().optional(),
|
||||||
options: z.array(z.object({ label: z.string(), description: z.string().optional() })).optional(),
|
options: z.array(z.object({ label: z.string(), description: z.string().optional() })).optional(),
|
||||||
multiSelect: z.boolean().optional(),
|
multiSelect: z.boolean().optional(),
|
||||||
}) satisfies z.ZodType<Wire<AskUserQuestionItem>>
|
}) satisfies z.ZodType<Wire<AskUserQuestionItem>>
|
||||||
@@ -27,7 +28,10 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
|
|||||||
z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }),
|
z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }),
|
||||||
z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }),
|
z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }),
|
||||||
z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }),
|
z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }),
|
||||||
z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema) }),
|
// Non-empty by wire contract: the user-interaction service rejects empty
|
||||||
|
// batches at ask() (EMPTY_QUESTIONS), so an empty frame is host breakage
|
||||||
|
// and must fail loud here, not reach the composer.
|
||||||
|
z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema).min(1) }),
|
||||||
z.object({ type: z.literal('question/resolved'), sessionId: sessionIdSchema, questionRpcId: rpcIdSchema, outcome: z.union([z.literal('answered'), z.literal('cancelled')]) }),
|
z.object({ type: z.literal('question/resolved'), sessionId: sessionIdSchema, questionRpcId: rpcIdSchema, outcome: z.union([z.literal('answered'), z.literal('cancelled')]) }),
|
||||||
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
|
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
|
||||||
]) as unknown as z.ZodType<MuxFrame>
|
]) as unknown as z.ZodType<MuxFrame>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const rpcIdSchema = z.string() as unknown as z.ZodType<RpcId>
|
|||||||
/** Error body: discriminated by code, per-branch details aligned to RpcErrorDetailsMap; details is required. */
|
/** Error body: discriminated by code, per-branch details aligned to RpcErrorDetailsMap; details is required. */
|
||||||
export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code', [
|
export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code', [
|
||||||
z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
|
z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
|
||||||
|
z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }),
|
||||||
z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
|
z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
|
||||||
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
|
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
|
||||||
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
|
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export function RpcId(id: string): RpcId {
|
|||||||
/** Error code → details type map (a second table isomorphic to RpcMethodMap). New code = one row here + one branch in the error schema. */
|
/** Error code → details type map (a second table isomorphic to RpcMethodMap). New code = one row here + one branch in the error schema. */
|
||||||
export interface RpcErrorDetailsMap {
|
export interface RpcErrorDetailsMap {
|
||||||
'bad-request': { issues: ZodIssue[] }
|
'bad-request': { issues: ZodIssue[] }
|
||||||
|
'cancelled': {}
|
||||||
'session-not-found': { sessionId: SessionId }
|
'session-not-found': { sessionId: SessionId }
|
||||||
'agent-busy': { reason: string }
|
'agent-busy': { reason: string }
|
||||||
'internal': {}
|
'internal': {}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ describe('RpcId', () => {
|
|||||||
describe('rpcErrorSchema', () => {
|
describe('rpcErrorSchema', () => {
|
||||||
it('accepts every code branch with its required details', () => {
|
it('accepts every code branch with its required details', () => {
|
||||||
expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
|
expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
|
||||||
|
expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled')
|
||||||
expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found')
|
expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found')
|
||||||
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
|
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
|
||||||
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
|
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
|
||||||
@@ -134,6 +135,10 @@ describe('events frame schemas', () => {
|
|||||||
expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q')
|
expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects an empty question batch (ask() guarantees at least one, so an empty frame is host breakage)', () => {
|
||||||
|
expect(() => muxFrameSchema.parse({ type: 'question/requested', sessionId: 's', questions: [] })).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
it('accepts every host frame branch', () => {
|
it('accepts every host frame branch', () => {
|
||||||
const frames = [
|
const frames = [
|
||||||
{ type: 'host/session-added', sessionId: 's', parentSessionId: 'p' },
|
{ type: 'host/session-added', sessionId: 's', parentSessionId: 'p' },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# @deepseek-ai/dsh-host-runtime
|
# @deepseek-ai/dsh-host-runtime
|
||||||
|
|
||||||
Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence, system prompt, tools, agents, agent loop, workspace instructions, local bash), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
|
Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence, system prompt, tools, agents, agent loop, workspace instructions, local bash, and the provider-neutral user-interaction service), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
|
||||||
|
|
||||||
Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it.
|
Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it.
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ Which plugins mount and with what defaults is decided only here — shells must
|
|||||||
|
|
||||||
## ApiProxy implementation notes
|
## ApiProxy implementation notes
|
||||||
|
|
||||||
Unary methods take the narrow `RpcRequest<P>` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). The mux stream replays a `session/subscribed` baseline per attached session on open; the host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position.
|
Unary methods take the narrow `RpcRequest<P>` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). The mux stream replays a `session/subscribed` baseline per attached session and every still-pending question with its original rpcId. Question responses, including blank per-item answers, are validated against the owning session and exact request before an atomic first-wins claim; answer, whole-request cancellation, owner abort, and provider disposal broadcast `question/resolved`. The host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position.
|
||||||
|
|
||||||
## Model Experience
|
## Model Experience
|
||||||
|
|
||||||
@@ -27,6 +27,6 @@ No direct invalidation; the mounted model-facing plugins own their request-prefi
|
|||||||
|
|
||||||
## Known Limitations and Deferred Work
|
## Known Limitations and Deferred Work
|
||||||
|
|
||||||
- **`respond` is a stub** — it always returns `not-pending`; the approval/question pending registry (stable-rpcId mint on accept, baseline replay on stream reopen, wire answerer) is the next host-side step.
|
- **Question waits are process-memory state** — browser reconnects recover them, but a host process restart aborts the owning tool call instead of restoring the wait from persistence.
|
||||||
- **`host.describe.version` is a placeholder** — it does not yet report the `apps/cli` package version.
|
- **`host.describe.version` is a placeholder** — it does not yet report the `apps/cli` package version.
|
||||||
- **The assembly is fixed** — per-deployment plugin selection (user profile, log sinks, alternative persistence) has a documented home here but no configuration surface yet.
|
- **The assembly is fixed** — per-deployment plugin selection (user profile, log sinks, alternative persistence) has a documented home here but no configuration surface yet.
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"@deepseek-ai/dsh-client-i18n": "workspace:^",
|
"@deepseek-ai/dsh-client-i18n": "workspace:^",
|
||||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-client-ui-question": "workspace:^",
|
||||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||||
@@ -69,6 +70,7 @@
|
|||||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||||
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
||||||
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^"
|
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Host-side ApiProxy implementation (minimal-first —
|
* Host-side ApiProxy implementation. Signature discipline: unary takes the
|
||||||
* describe/list/create/history/prompt/cancel and both streams are real,
|
* narrow RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
|
||||||
* respond is a stub). Signature discipline: unary takes the narrow
|
|
||||||
* RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
@@ -12,9 +10,16 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
|||||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||||
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||||
import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, SessionSummary, ToolEventView } from '@deepseek-ai/dsh-host-apiproxy/api'
|
import type {
|
||||||
|
ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
|
||||||
|
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||||
|
import { questionResponsePayloadSchema } from '@deepseek-ai/dsh-host-apiproxy/api/questions.schema'
|
||||||
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||||
|
import type {
|
||||||
|
AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
|
||||||
|
} from '@deepseek-ai/dsh-user-interaction'
|
||||||
|
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
|
||||||
|
|
||||||
/** Page size when history is called without maxMessages. */
|
/** Page size when history is called without maxMessages. */
|
||||||
const DEFAULT_MAX_MESSAGES = 50
|
const DEFAULT_MAX_MESSAGES = 50
|
||||||
@@ -155,6 +160,35 @@ interface ToolCallData { callId: string; name: string; arguments: string }
|
|||||||
/** The tool/result payload fields the presenter path reads. */
|
/** The tool/result payload fields the presenter path reads. */
|
||||||
interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: JsonValue }
|
interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: JsonValue }
|
||||||
|
|
||||||
|
/** One host-owned question wait, addressed by the stable server-request id. */
|
||||||
|
interface PendingQuestion {
|
||||||
|
rpcId: RpcId
|
||||||
|
sessionId: SessionId
|
||||||
|
questions: AskUserQuestionItem[]
|
||||||
|
resolve: (answer: AskUserQuestionAnswer) => void
|
||||||
|
reject: (error: UserInteractionError) => void
|
||||||
|
signal?: AbortSignal
|
||||||
|
onAbort?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate one answer batch against the exact question request it resolves. */
|
||||||
|
function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQuestion): boolean {
|
||||||
|
if (payload.sessionId !== pending.sessionId) return false
|
||||||
|
const answers = payload.answer.answers
|
||||||
|
if (answers.length !== pending.questions.length) return false
|
||||||
|
return answers.every((answer, index) => {
|
||||||
|
const question = pending.questions[index] as AskUserQuestionItem
|
||||||
|
if (answer.id !== question.id) return false
|
||||||
|
if (new Set(answer.selected).size !== answer.selected.length) return false
|
||||||
|
const custom = answer.custom?.trim()
|
||||||
|
if (custom !== undefined && custom === '') return false
|
||||||
|
if (custom !== undefined && answer.selected.length > 0) return false
|
||||||
|
if (question.multiSelect !== true && answer.selected.length > 1) return false
|
||||||
|
const labels = new Set(question.options?.map(option => option.label) ?? [])
|
||||||
|
return answer.selected.every(label => labels.has(label))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the render intent for a tool/call or tool/result event through the
|
* Compute the render intent for a tool/call or tool/result event through the
|
||||||
* presenters registered at this moment; every other event type gets none. A
|
* presenters registered at this moment; every other event type gets none. A
|
||||||
@@ -219,12 +253,70 @@ class SessionNotFound extends Error {}
|
|||||||
* @param ctx - the root context returned by bootHost (sessions/agents services mounted).
|
* @param ctx - the root context returned by bootHost (sessions/agents services mounted).
|
||||||
* @param defaults - host-level default provider/model: injected as
|
* @param defaults - host-level default provider/model: injected as
|
||||||
* agentOptions on create/resume, reported by describe from the same source.
|
* agentOptions on create/resume, reported by describe from the same source.
|
||||||
* @returns the ApiProxy implementation (minimal-first; stubs noted per method).
|
* @returns the ApiProxy implementation.
|
||||||
*/
|
*/
|
||||||
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
|
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
|
||||||
const agentOptions = { provider: defaults.provider, model: defaults.model }
|
const agentOptions = { provider: defaults.provider, model: defaults.model }
|
||||||
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
|
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
|
||||||
const resumes = new Map<SessionId, Promise<Agent>>()
|
const resumes = new Map<SessionId, Promise<Agent>>()
|
||||||
|
const pendingQuestions = new Map<RpcId, PendingQuestion>()
|
||||||
|
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
|
||||||
|
|
||||||
|
/** Send one transient frame to every connected mux consumer. */
|
||||||
|
function broadcast(payload: MuxFrame): void {
|
||||||
|
const envelope = frame(payload)
|
||||||
|
for (const queue of muxQueues) queue.push(envelope)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a wait before settling it: synchronous deletion makes the first claimant win. */
|
||||||
|
function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void {
|
||||||
|
pendingQuestions.delete(pending.rpcId)
|
||||||
|
if (pending.signal !== undefined && pending.onAbort !== undefined) {
|
||||||
|
pending.signal.removeEventListener('abort', pending.onAbort)
|
||||||
|
}
|
||||||
|
broadcast({
|
||||||
|
type: 'question/resolved', sessionId: pending.sessionId,
|
||||||
|
questionRpcId: pending.rpcId, outcome,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const disposeProvider = ctx.userInteraction.registerProvider({
|
||||||
|
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
|
||||||
|
const sessionId = request.agent?.id
|
||||||
|
if (sessionId === undefined) {
|
||||||
|
return Promise.reject(new UserInteractionError(
|
||||||
|
'web user interaction requires an agent-owned session', 'ASK_MISSING_AGENT'))
|
||||||
|
}
|
||||||
|
return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
|
||||||
|
const rpcId = RpcId(randomUUID())
|
||||||
|
const pending: PendingQuestion = {
|
||||||
|
rpcId, sessionId, questions: request.questions, resolve, reject,
|
||||||
|
...(request.signal === undefined ? {} : { signal: request.signal }),
|
||||||
|
}
|
||||||
|
const onAbort = (): void => {
|
||||||
|
claimQuestion(pending, 'cancelled')
|
||||||
|
reject(new UserInteractionError(
|
||||||
|
'ask_user_question was aborted before the user answered', 'ASK_ABORTED'))
|
||||||
|
}
|
||||||
|
pending.onAbort = onAbort
|
||||||
|
pendingQuestions.set(rpcId, pending)
|
||||||
|
request.signal?.addEventListener('abort', onAbort, { once: true })
|
||||||
|
const envelope: RpcRequest<MuxFrame> = {
|
||||||
|
rpcId,
|
||||||
|
payload: { type: 'question/requested', sessionId, questions: request.questions },
|
||||||
|
}
|
||||||
|
for (const queue of muxQueues) queue.push(envelope)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
ctx.effect(() => () => {
|
||||||
|
disposeProvider()
|
||||||
|
for (const pending of [...pendingQuestions.values()]) {
|
||||||
|
claimQuestion(pending, 'cancelled')
|
||||||
|
pending.reject(new UserInteractionError(
|
||||||
|
'web user-interaction provider was disposed', 'ASK_ABORTED'))
|
||||||
|
}
|
||||||
|
}, 'api-proxy: user-interaction provider')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gate the cold path on the store: an id absent from it, or naming a legacy
|
* Gate the cold path on the store: an id absent from it, or naming a legacy
|
||||||
@@ -361,9 +453,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
events: {
|
events: {
|
||||||
mux(_request, signal) {
|
mux(_request, signal) {
|
||||||
const queue = new FrameQueue<RpcRequest<MuxFrame>>()
|
const queue = new FrameQueue<RpcRequest<MuxFrame>>()
|
||||||
|
muxQueues.add(queue)
|
||||||
for (const session of ctx.sessions.list()) {
|
for (const session of ctx.sessions.list()) {
|
||||||
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
|
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
|
||||||
}
|
}
|
||||||
|
for (const pending of pendingQuestions.values()) {
|
||||||
|
queue.push({
|
||||||
|
rpcId: pending.rpcId,
|
||||||
|
payload: {
|
||||||
|
type: 'question/requested', sessionId: pending.sessionId,
|
||||||
|
questions: pending.questions,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
// Per-session open-call table for result-view pairing. Bounded by the
|
// Per-session open-call table for result-view pairing. Bounded by the
|
||||||
// per-turn call count: entries clear on turn/end; a table miss (stream
|
// per-turn call count: entries clear on turn/end; a table miss (stream
|
||||||
// opened mid-turn) backscans the session's in-memory events instead.
|
// opened mid-turn) backscans the session's in-memory events instead.
|
||||||
@@ -393,7 +495,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
openCalls.delete(session.id)
|
openCalls.delete(session.id)
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
|
return queue.iterate(signal, () => {
|
||||||
|
muxQueues.delete(queue)
|
||||||
|
for (const dispose of disposers) dispose()
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
host(_request, signal) {
|
host(_request, signal) {
|
||||||
@@ -421,9 +526,38 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// TODO(step2): approval/question pending registry (wire answerer + proxy provider).
|
respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||||
respond(_message: ClientResponse): Promise<RpcReceipt> {
|
const pending = pendingQuestions.get(message.rpcId)
|
||||||
return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
||||||
|
if (!message.result.ok) {
|
||||||
|
if (message.result.error.code !== 'cancelled') {
|
||||||
|
return Promise.resolve({ accepted: false, reason: 'bad-response' })
|
||||||
|
}
|
||||||
|
claimQuestion(pending, 'cancelled')
|
||||||
|
pending.reject(new UserInteractionError(
|
||||||
|
'the user cancelled ask_user_question', 'ASK_CANCELLED'))
|
||||||
|
return Promise.resolve({ accepted: true })
|
||||||
|
}
|
||||||
|
const parsed = questionResponsePayloadSchema.safeParse(message.result.value)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return Promise.resolve({ accepted: false, reason: 'bad-response' })
|
||||||
|
}
|
||||||
|
const payload: QuestionResponsePayload = {
|
||||||
|
sessionId: parsed.data.sessionId,
|
||||||
|
answer: {
|
||||||
|
answers: parsed.data.answer.answers.map(answer => ({
|
||||||
|
id: answer.id,
|
||||||
|
selected: answer.selected,
|
||||||
|
...(answer.custom === undefined ? {} : { custom: answer.custom }),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (!matchesQuestions(payload, pending)) {
|
||||||
|
return Promise.resolve({ accepted: false, reason: 'bad-response' })
|
||||||
|
}
|
||||||
|
claimQuestion(pending, 'answered')
|
||||||
|
pending.resolve(payload.answer)
|
||||||
|
return Promise.resolve({ accepted: true })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import * as toolWorkflow from '@deepseek-ai/dsh-tool-workflow'
|
|||||||
import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
|
import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
|
||||||
import SpillLocal from '@deepseek-ai/dsh-spill-local'
|
import SpillLocal from '@deepseek-ai/dsh-spill-local'
|
||||||
import * as spillPolicy from '@deepseek-ai/dsh-spill-policy'
|
import * as spillPolicy from '@deepseek-ai/dsh-spill-policy'
|
||||||
|
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||||
|
|
||||||
/** Options for bootHost — the assembly-layer composition knobs. */
|
/** Options for bootHost — the assembly-layer composition knobs. */
|
||||||
export interface BootHostOptions {
|
export interface BootHostOptions {
|
||||||
@@ -94,6 +95,7 @@ export async function bootHost(options: BootHostOptions): Promise<HostHandle> {
|
|||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||||
await ctx.plugin(ToolRegistry)
|
await ctx.plugin(ToolRegistry)
|
||||||
|
await ctx.plugin(UserInteractionService)
|
||||||
await ctx.plugin(AgentRegistry)
|
await ctx.plugin(AgentRegistry)
|
||||||
await ctx.plugin(TaskService)
|
await ctx.plugin(TaskService)
|
||||||
await ctx.plugin(AgentLoop, { agents: [] })
|
await ctx.plugin(AgentLoop, { agents: [] })
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Web UI plugin assembly: mounts @cordisjs/plugin-loader with an in-memory
|
* Web UI plugin assembly: mounts @cordisjs/plugin-loader with an in-memory
|
||||||
* entry tree listing the eight UI plugin packages (the P-I config-source bar —
|
* entry tree listing the nine UI plugin packages (the P-I config-source bar —
|
||||||
* a cordis.yml file form comes later; install/remove currently means editing
|
* a cordis.yml file form comes later; install/remove currently means editing
|
||||||
* this list and restarting). The web plugin registry discovers the entries by
|
* this list and restarting). The web plugin registry discovers the entries by
|
||||||
* their package.json dshClient declarations; node halves are empty applies,
|
* their package.json dshClient declarations; feature packages may also mount
|
||||||
* so mounting them here costs nothing beyond Loader governance.
|
* their interface-specific host half through the same lifecycle.
|
||||||
*/
|
*/
|
||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import Loader from '@cordisjs/plugin-loader'
|
import Loader from '@cordisjs/plugin-loader'
|
||||||
|
|
||||||
/** The eight UI plugin packages served to the browser (order = manifest order). */
|
/** The nine UI plugin packages served to the browser (order = manifest order). */
|
||||||
export const WEB_UI_PLUGINS = [
|
export const WEB_UI_PLUGINS = [
|
||||||
'@deepseek-ai/dsh-client-connection',
|
'@deepseek-ai/dsh-client-connection',
|
||||||
'@deepseek-ai/dsh-client-runtime',
|
'@deepseek-ai/dsh-client-runtime',
|
||||||
@@ -19,6 +19,7 @@ export const WEB_UI_PLUGINS = [
|
|||||||
'@deepseek-ai/dsh-client-ui-layout',
|
'@deepseek-ai/dsh-client-ui-layout',
|
||||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
'@deepseek-ai/dsh-client-ui-sidebar',
|
||||||
'@deepseek-ai/dsh-client-ui-conversation',
|
'@deepseek-ai/dsh-client-ui-conversation',
|
||||||
|
'@deepseek-ai/dsh-client-ui-question',
|
||||||
'@deepseek-ai/dsh-client-ui-trajectory',
|
'@deepseek-ai/dsh-client-ui-trajectory',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ export interface MountedWebPlugins {
|
|||||||
export async function mountWebPlugins(ctx: Context): Promise<MountedWebPlugins> {
|
export async function mountWebPlugins(ctx: Context): Promise<MountedWebPlugins> {
|
||||||
// The Loader resolves bare specifiers against ctx.baseUrl; without one the
|
// The Loader resolves bare specifiers against ctx.baseUrl; without one the
|
||||||
// import silently fails and every entry stays fiber-less. This package
|
// import silently fails and every entry stays fiber-less. This package
|
||||||
// depends on all eight UI plugins, so its own URL is the right anchor.
|
// depends on all nine UI plugins, so its own URL is the right anchor.
|
||||||
ctx.baseUrl ??= import.meta.url
|
ctx.baseUrl ??= import.meta.url
|
||||||
if (ctx.get('loader') === undefined) await ctx.plugin(Loader)
|
if (ctx.get('loader') === undefined) await ctx.plugin(Loader)
|
||||||
const existing = new Set([...ctx.loader.entries()].map(entry => entry.options.name))
|
const existing = new Set([...ctx.loader.entries()].map(entry => entry.options.name))
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import SessionStore from '@deepseek-ai/dsh-session'
|
import SessionStore from '@deepseek-ai/dsh-session'
|
||||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||||
|
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||||
@@ -32,6 +33,7 @@ describe('sessions.list cold merge', () => {
|
|||||||
it('summarizes unattached sessions: log mtime, locate-less and vanished-log createdAt fallbacks, lineage', async () => {
|
it('summarizes unattached sessions: log mtime, locate-less and vanished-log createdAt fallbacks, lineage', async () => {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
|
await ctx.plugin(UserInteractionService)
|
||||||
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
|
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
|
||||||
const logPath = join(root, 'a.log')
|
const logPath = join(root, 'a.log')
|
||||||
writeFileSync(logPath, 'log-bytes')
|
writeFileSync(logPath, 'log-bytes')
|
||||||
@@ -76,6 +78,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
|
|||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
await ctx.plugin(AgentRegistry)
|
await ctx.plugin(AgentRegistry)
|
||||||
|
await ctx.plugin(UserInteractionService)
|
||||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||||
|
|
||||||
const listed = await api.sessions.list(request({}))
|
const listed = await api.sessions.list(request({}))
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|||||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||||
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
|
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||||
|
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||||
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||||
import { createApiProxy } from '../src/api-proxy.ts'
|
import { createApiProxy } from '../src/api-proxy.ts'
|
||||||
@@ -39,6 +40,7 @@ async function harness(): Promise<{ ctx: Context }> {
|
|||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||||
await ctx.plugin(ToolRegistry)
|
await ctx.plugin(ToolRegistry)
|
||||||
|
await ctx.plugin(UserInteractionService)
|
||||||
await ctx.plugin(AgentRegistry)
|
await ctx.plugin(AgentRegistry)
|
||||||
ctx.tools.register(tool('gen', {
|
ctx.tools.register(tool('gen', {
|
||||||
presentCall: () => ({ card: 'generic', title: 'gen call' }),
|
presentCall: () => ({ card: 'generic', title: 'gen call' }),
|
||||||
|
|||||||
@@ -418,10 +418,175 @@ describe('events streams', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('respond stub', () => {
|
describe('question request / response', () => {
|
||||||
it('always reports not-pending (step2 registry pending)', async () => {
|
const questions = [{
|
||||||
const { api } = await boot()
|
id: 'mode', question: 'Choose a mode',
|
||||||
const receipt = await api.respond({ type: 'client-response', rpcId: RpcId('r'), result: { ok: true, value: null } })
|
options: [
|
||||||
expect(receipt).toEqual({ accepted: false, reason: 'not-pending' })
|
{ label: 'Fast (Recommended)', description: 'Move quickly.' },
|
||||||
|
{ label: 'Careful', description: 'Review first.' },
|
||||||
|
],
|
||||||
|
}]
|
||||||
|
|
||||||
|
it('waits, replays the same rpcId on reconnect, validates, and resolves first-wins', async () => {
|
||||||
|
const running = await boot()
|
||||||
|
const { api, ctx } = running
|
||||||
|
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||||
|
const agent = ctx.agents.get(sessionId) as Agent
|
||||||
|
const ac = new AbortController()
|
||||||
|
const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
|
||||||
|
await stream.next() // subscribed baseline starts the generator and installs the queue
|
||||||
|
|
||||||
|
const answerPromise = ctx.userInteraction.ask({ questions, agent })
|
||||||
|
const requested = (await stream.next()).value as RpcRequest<MuxFrame>
|
||||||
|
expect(requested.payload).toMatchObject({ type: 'question/requested', sessionId, questions })
|
||||||
|
|
||||||
|
const wrongSession = await api.respond({
|
||||||
|
type: 'client-response', rpcId: requested.rpcId,
|
||||||
|
result: {
|
||||||
|
ok: true,
|
||||||
|
value: { sessionId: 'session-other', answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }] } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(wrongSession).toEqual({ accepted: false, reason: 'bad-response' })
|
||||||
|
const badChoice = await api.respond({
|
||||||
|
type: 'client-response', rpcId: requested.rpcId,
|
||||||
|
result: {
|
||||||
|
ok: true,
|
||||||
|
value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Unknown'] }] } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(badChoice).toEqual({ accepted: false, reason: 'bad-response' })
|
||||||
|
const invalidResults = [
|
||||||
|
{ ok: true as const, value: null },
|
||||||
|
{ ok: true as const, value: { sessionId, answer: { answers: [] } } },
|
||||||
|
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'wrong', selected: ['Fast (Recommended)'] }] } } },
|
||||||
|
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)', 'Fast (Recommended)'] }] } } },
|
||||||
|
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)', 'Careful'] }] } } },
|
||||||
|
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: [], custom: ' ' }] } } },
|
||||||
|
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Careful'], custom: 'Other' }] } } },
|
||||||
|
{ ok: false as const, error: { code: 'internal' as const, message: 'wrong error', details: {} } },
|
||||||
|
]
|
||||||
|
for (const result of invalidResults) {
|
||||||
|
expect(await api.respond({
|
||||||
|
type: 'client-response', rpcId: requested.rpcId, result,
|
||||||
|
})).toEqual({ accepted: false, reason: 'bad-response' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const reconnectAbort = new AbortController()
|
||||||
|
const replay = api.events.mux(request({}), reconnectAbort.signal)[Symbol.asyncIterator]()
|
||||||
|
await replay.next()
|
||||||
|
const replayed = (await replay.next()).value as RpcRequest<MuxFrame>
|
||||||
|
expect(replayed.rpcId).toBe(requested.rpcId)
|
||||||
|
expect(replayed.payload).toEqual(requested.payload)
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
type: 'client-response' as const,
|
||||||
|
rpcId: requested.rpcId,
|
||||||
|
result: {
|
||||||
|
ok: true as const,
|
||||||
|
value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }] } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const [first, duplicate] = await Promise.all([api.respond(response), api.respond(response)])
|
||||||
|
expect([first, duplicate]).toContainEqual({ accepted: true })
|
||||||
|
expect([first, duplicate]).toContainEqual({ accepted: false, reason: 'not-pending' })
|
||||||
|
await expect(answerPromise).resolves.toEqual({
|
||||||
|
answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolved = (await stream.next()).value as RpcRequest<MuxFrame>
|
||||||
|
expect(resolved.payload).toMatchObject({
|
||||||
|
type: 'question/resolved', sessionId, questionRpcId: requested.rpcId, outcome: 'answered',
|
||||||
|
})
|
||||||
|
expect(await api.respond(response)).toEqual({ accepted: false, reason: 'not-pending' })
|
||||||
|
|
||||||
|
const customQuestions = [{ id: 'detail', question: 'What else?' }]
|
||||||
|
const customAnswer = ctx.userInteraction.ask({ questions: customQuestions, agent })
|
||||||
|
const customRequested = (await stream.next()).value as RpcRequest<MuxFrame>
|
||||||
|
expect(await api.respond({
|
||||||
|
type: 'client-response', rpcId: customRequested.rpcId,
|
||||||
|
result: {
|
||||||
|
ok: true,
|
||||||
|
value: { sessionId, answer: { answers: [{ id: 'detail', selected: [], custom: 'Keep traces' }] } },
|
||||||
|
},
|
||||||
|
})).toEqual({ accepted: true })
|
||||||
|
await expect(customAnswer).resolves.toEqual({
|
||||||
|
answers: [{ id: 'detail', selected: [], custom: 'Keep traces' }],
|
||||||
|
})
|
||||||
|
expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
|
||||||
|
type: 'question/resolved', questionRpcId: customRequested.rpcId, outcome: 'answered',
|
||||||
|
})
|
||||||
|
|
||||||
|
const blankAnswer = ctx.userInteraction.ask({ questions, agent })
|
||||||
|
const blankRequested = (await stream.next()).value as RpcRequest<MuxFrame>
|
||||||
|
expect(await api.respond({
|
||||||
|
type: 'client-response', rpcId: blankRequested.rpcId,
|
||||||
|
result: {
|
||||||
|
ok: true,
|
||||||
|
value: { sessionId, answer: { answers: [{ id: 'mode', selected: [] }] } },
|
||||||
|
},
|
||||||
|
})).toEqual({ accepted: true })
|
||||||
|
await expect(blankAnswer).resolves.toEqual({
|
||||||
|
answers: [{ id: 'mode', selected: [] }],
|
||||||
|
})
|
||||||
|
expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
|
||||||
|
type: 'question/resolved', questionRpcId: blankRequested.rpcId, outcome: 'answered',
|
||||||
|
})
|
||||||
|
ac.abort()
|
||||||
|
reconnectAbort.abort()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('distinguishes user cancellation from owner abort and rejects late responses', async () => {
|
||||||
|
const running = await boot()
|
||||||
|
const { api, ctx } = running
|
||||||
|
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||||
|
const agent = ctx.agents.get(sessionId) as Agent
|
||||||
|
const streamAbort = new AbortController()
|
||||||
|
const stream = api.events.mux(request({}), streamAbort.signal)[Symbol.asyncIterator]()
|
||||||
|
await stream.next()
|
||||||
|
|
||||||
|
const cancelled = ctx.userInteraction.ask({ questions, agent }).catch((error: unknown) => error)
|
||||||
|
const requested = (await stream.next()).value as RpcRequest<MuxFrame>
|
||||||
|
expect(await api.respond({
|
||||||
|
type: 'client-response', rpcId: requested.rpcId,
|
||||||
|
result: { ok: false, error: { code: 'cancelled', message: 'skip', details: {} } },
|
||||||
|
})).toEqual({ accepted: true })
|
||||||
|
await expect(cancelled).resolves.toMatchObject({ code: 'ASK_CANCELLED' })
|
||||||
|
expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
|
||||||
|
type: 'question/resolved', outcome: 'cancelled',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ownerAbort = new AbortController()
|
||||||
|
const aborted = ctx.userInteraction.ask({ questions, agent, signal: ownerAbort.signal })
|
||||||
|
.catch((error: unknown) => error)
|
||||||
|
const abortRequest = (await stream.next()).value as RpcRequest<MuxFrame>
|
||||||
|
ownerAbort.abort()
|
||||||
|
await expect(aborted).resolves.toMatchObject({ code: 'ASK_ABORTED' })
|
||||||
|
expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
|
||||||
|
type: 'question/resolved', questionRpcId: abortRequest.rpcId, outcome: 'cancelled',
|
||||||
|
})
|
||||||
|
expect(await api.respond({
|
||||||
|
type: 'client-response', rpcId: abortRequest.rpcId,
|
||||||
|
result: { ok: false, error: { code: 'cancelled', message: 'late', details: {} } },
|
||||||
|
})).toEqual({ accepted: false, reason: 'not-pending' })
|
||||||
|
streamAbort.abort()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects missing routing and pre-abort, then aborts outstanding waits on disposal', async () => {
|
||||||
|
const running = await boot()
|
||||||
|
const { ctx } = running
|
||||||
|
await expect(ctx.userInteraction.ask({ questions })).rejects.toMatchObject({ code: 'ASK_MISSING_AGENT' })
|
||||||
|
const { sessionId } = expectOk(await running.api.sessions.create(request({})))
|
||||||
|
const agent = ctx.agents.get(sessionId) as Agent
|
||||||
|
const alreadyAborted = new AbortController()
|
||||||
|
alreadyAborted.abort()
|
||||||
|
await expect(ctx.userInteraction.ask({ questions, agent, signal: alreadyAborted.signal }))
|
||||||
|
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||||
|
|
||||||
|
const outstanding = ctx.userInteraction.ask({ questions, agent })
|
||||||
|
const disposed = running.dispose()
|
||||||
|
host = undefined
|
||||||
|
await expect(outstanding).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||||
|
await disposed
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Web UI plugin assembly: the in-memory Loader tree mounts all eight UI
|
* Web UI plugin assembly: the in-memory Loader tree mounts all nine UI
|
||||||
* packages (node halves), and the webserver registry built over it yields the
|
* packages (node halves), and the webserver registry built over it yields the
|
||||||
* full __DSH_BOOT__ manifest — the P-I config-source bar end to end.
|
* full __DSH_BOOT__ manifest — the P-I config-source bar end to end.
|
||||||
*
|
*
|
||||||
@@ -10,6 +10,9 @@
|
|||||||
import { existsSync } from 'node:fs'
|
import { existsSync } from 'node:fs'
|
||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
|
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||||
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
|
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import { createHostWebPluginRegistry } from '@deepseek-ai/dsh-host-webserver'
|
import { createHostWebPluginRegistry } from '@deepseek-ai/dsh-host-webserver'
|
||||||
import { WEB_UI_PLUGINS, mountWebPlugins } from '../src/web-plugins.ts'
|
import { WEB_UI_PLUGINS, mountWebPlugins } from '../src/web-plugins.ts'
|
||||||
@@ -31,8 +34,16 @@ afterEach(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe.skipIf(!built)('mountWebPlugins + registry', () => {
|
describe.skipIf(!built)('mountWebPlugins + registry', () => {
|
||||||
it('mounts the eight-package in-memory Loader tree and projects the boot manifest', async () => {
|
async function rootWithHostServices(): Promise<Context> {
|
||||||
root = new Context()
|
root = new Context()
|
||||||
|
await root.plugin(SystemPrompt)
|
||||||
|
await root.plugin(ToolRegistry)
|
||||||
|
await root.plugin(UserInteractionService)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
it('mounts the nine-package in-memory Loader tree and projects the boot manifest', async () => {
|
||||||
|
root = await rootWithHostServices()
|
||||||
const mounted = await mountWebPlugins(root)
|
const mounted = await mountWebPlugins(root)
|
||||||
const registry = createHostWebPluginRegistry({
|
const registry = createHostWebPluginRegistry({
|
||||||
ctx: root,
|
ctx: root,
|
||||||
@@ -59,7 +70,7 @@ describe.skipIf(!built)('mountWebPlugins + registry', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('is idempotent: a second mount reuses the loader and creates no duplicate entries', async () => {
|
it('is idempotent: a second mount reuses the loader and creates no duplicate entries', async () => {
|
||||||
root = new Context()
|
root = await rootWithHostServices()
|
||||||
await mountWebPlugins(root)
|
await mountWebPlugins(root)
|
||||||
const second = await mountWebPlugins(root)
|
const second = await mountWebPlugins(root)
|
||||||
// ctx.loader hands out a fresh traced proxy per access, so loader identity
|
// ctx.loader hands out a fresh traced proxy per access, so loader identity
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* mountWebPlugins unit coverage (keyless; the real eight-package walk is the
|
* mountWebPlugins unit coverage (keyless; the real nine-package walk is the
|
||||||
* built-artifact e2e). The Loader-facing behavior — baseUrl anchoring, entry
|
* built-artifact e2e). The Loader-facing behavior — baseUrl anchoring, entry
|
||||||
* creation with idempotent reuse, the fiber-less fail-loud sweep, and the
|
* creation with idempotent reuse, the fiber-less fail-loud sweep, and the
|
||||||
* resolver seam — is exercised against a stubbed loader service so it runs
|
* resolver seam — is exercised against a stubbed loader service so it runs
|
||||||
@@ -85,7 +85,7 @@ describe('mountWebPlugins (stubbed loader)', () => {
|
|||||||
|
|
||||||
it('mounts the real Loader when none is present (the ctx.plugin(Loader) branch)', async () => {
|
it('mounts the real Loader when none is present (the ctx.plugin(Loader) branch)', async () => {
|
||||||
root = new Context()
|
root = new Context()
|
||||||
// Environment-dependent outcome: with built lib/ the eight imports load
|
// Environment-dependent outcome: with built lib/ the nine imports load
|
||||||
// and the mount resolves; without them every entry stays fiber-less and
|
// and the mount resolves; without them every entry stays fiber-less and
|
||||||
// the sweep throws its loud list. Either way the branch under test is the
|
// the sweep throws its loud list. Either way the branch under test is the
|
||||||
// Loader auto-mount. Manual try/catch keeps cordis-traced proxies out of
|
// Loader auto-mount. Manual try/catch keeps cordis-traced proxies out of
|
||||||
@@ -100,7 +100,7 @@ describe('mountWebPlugins (stubbed loader)', () => {
|
|||||||
}
|
}
|
||||||
expect(outcome === 'resolved' || /UI plugin\(s\) failed to load/.test(outcome)).toBe(true)
|
expect(outcome === 'resolved' || /UI plugin\(s\) failed to load/.test(outcome)).toBe(true)
|
||||||
expect(root.get('loader') !== undefined).toBe(true)
|
expect(root.get('loader') !== undefined).toBe(true)
|
||||||
}, 30_000) // built-env run imports eight real plugin packages through the Loader
|
}, 30_000) // built-env run imports nine real plugin packages through the Loader
|
||||||
|
|
||||||
it('keeps a caller-set baseUrl (anchors only when absent)', async () => {
|
it('keeps a caller-set baseUrl (anchors only when absent)', async () => {
|
||||||
const entriesList: FakeEntry[] = []
|
const entriesList: FakeEntry[] = []
|
||||||
|
|||||||
@@ -140,6 +140,9 @@
|
|||||||
{
|
{
|
||||||
"path": "../../client/ui-conversation"
|
"path": "../../client/ui-conversation"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "../../client/ui-question"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "../../client/ui-trajectory"
|
"path": "../../client/ui-trajectory"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod
|
|||||||
- `UserInteractionProvider` — UI implementation with `ask(request)`.
|
- `UserInteractionProvider` — UI implementation with `ask(request)`.
|
||||||
- `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
|
- `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
|
||||||
|
|
||||||
When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices.
|
When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch.
|
||||||
|
|
||||||
## Role
|
## Role
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export interface AskUserQuestionItem {
|
|||||||
export interface AskUserQuestionAnswerItem {
|
export interface AskUserQuestionAnswerItem {
|
||||||
/** The answered question id. */
|
/** The answered question id. */
|
||||||
id: string
|
id: string
|
||||||
/** Selected option labels. Empty when the answer is purely custom text. */
|
/** Selected option labels. Empty for custom or unanswered choices. */
|
||||||
selected: string[]
|
selected: string[]
|
||||||
/** Optional free-text "Other" answer. */
|
/** Optional free-text "Other" answer. */
|
||||||
custom?: string
|
custom?: string
|
||||||
|
|||||||
55
pnpm-lock.yaml
generated
55
pnpm-lock.yaml
generated
@@ -631,6 +631,55 @@ importers:
|
|||||||
specifier: ^4.0.0-rc.7
|
specifier: ^4.0.0-rc.7
|
||||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||||
|
|
||||||
|
packages/client/ui-question:
|
||||||
|
dependencies:
|
||||||
|
'@deepseek-ai/dsh-client-connection':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../connection
|
||||||
|
'@deepseek-ai/dsh-client-runtime':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../runtime
|
||||||
|
'@deepseek-ai/dsh-client-ui-conversation':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../ui-conversation
|
||||||
|
'@deepseek-ai/dsh-client-ui-primitives':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../ui-primitives
|
||||||
|
'@deepseek-ai/dsh-client-ui-slots':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../ui-slots
|
||||||
|
'@deepseek-ai/dsh-tool-ask-user':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../ui/tool-ask-user
|
||||||
|
clsx:
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.1.1
|
||||||
|
react:
|
||||||
|
specifier: ^18.2.0
|
||||||
|
version: 18.3.1
|
||||||
|
devDependencies:
|
||||||
|
'@deepseek-ai/dsh-agent':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../core/agent
|
||||||
|
'@deepseek-ai/dsh-invariants':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../support/invariants
|
||||||
|
'@deepseek-ai/dsh-system-prompt':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../core/system-prompt
|
||||||
|
'@deepseek-ai/dsh-tools':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../core/tools
|
||||||
|
'@deepseek-ai/dsh-user-interaction':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../ui/user-interaction
|
||||||
|
'@types/react':
|
||||||
|
specifier: ~18.3.1
|
||||||
|
version: 18.3.31
|
||||||
|
cordis:
|
||||||
|
specifier: ^4.0.0-rc.7
|
||||||
|
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||||
|
|
||||||
packages/client/ui-sidebar:
|
packages/client/ui-sidebar:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@deepseek-ai/dsh-client-runtime':
|
'@deepseek-ai/dsh-client-runtime':
|
||||||
@@ -1984,6 +2033,9 @@ importers:
|
|||||||
'@deepseek-ai/dsh-client-ui-layout':
|
'@deepseek-ai/dsh-client-ui-layout':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../client/ui-layout
|
version: link:../../client/ui-layout
|
||||||
|
'@deepseek-ai/dsh-client-ui-question':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../client/ui-question
|
||||||
'@deepseek-ai/dsh-client-ui-sidebar':
|
'@deepseek-ai/dsh-client-ui-sidebar':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../client/ui-sidebar
|
version: link:../../client/ui-sidebar
|
||||||
@@ -2080,6 +2132,9 @@ importers:
|
|||||||
'@deepseek-ai/dsh-tools':
|
'@deepseek-ai/dsh-tools':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../core/tools
|
version: link:../../core/tools
|
||||||
|
'@deepseek-ai/dsh-user-interaction':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../ui/user-interaction
|
||||||
'@deepseek-ai/dsh-workflow-workerthread':
|
'@deepseek-ai/dsh-workflow-workerthread':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../workflow/workflow-workerthread
|
version: link:../../workflow/workflow-workerthread
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
|||||||
'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||||
'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||||
'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||||
|
'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' },
|
||||||
'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||||
'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||||
'packages/client/i18n': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
'packages/client/i18n': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||||
|
|||||||
@@ -108,6 +108,7 @@
|
|||||||
"@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"],
|
"@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"],
|
||||||
"@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"],
|
"@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"],
|
||||||
"@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"],
|
"@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"],
|
||||||
|
"@deepseek-ai/dsh-client-ui-question": ["./packages/client/ui-question/src"],
|
||||||
"@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"],
|
"@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"],
|
||||||
"@deepseek-ai/dsh-client-ui-theme": ["./packages/client/ui-theme/src"],
|
"@deepseek-ai/dsh-client-ui-theme": ["./packages/client/ui-theme/src"],
|
||||||
"@deepseek-ai/dsh-client-i18n": ["./packages/client/i18n/src"],
|
"@deepseek-ai/dsh-client-i18n": ["./packages/client/i18n/src"],
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
{ "path": "./packages/client/ui-layout" },
|
{ "path": "./packages/client/ui-layout" },
|
||||||
{ "path": "./packages/client/ui-sidebar" },
|
{ "path": "./packages/client/ui-sidebar" },
|
||||||
{ "path": "./packages/client/ui-conversation" },
|
{ "path": "./packages/client/ui-conversation" },
|
||||||
|
{ "path": "./packages/client/ui-question" },
|
||||||
{ "path": "./packages/client/ui-trajectory" },
|
{ "path": "./packages/client/ui-trajectory" },
|
||||||
{ "path": "./packages/client/ui-theme" },
|
{ "path": "./packages/client/ui-theme" },
|
||||||
{ "path": "./packages/client/i18n" },
|
{ "path": "./packages/client/i18n" },
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export default defineConfig({
|
|||||||
// branches need a browser-grade harness the jsdom lane doesn't cover
|
// branches need a browser-grade harness the jsdom lane doesn't cover
|
||||||
// yet. TODO(gui): cover and remove as the client test lane matures.
|
// yet. TODO(gui): cover and remove as the client test lane matures.
|
||||||
'packages/client/ui-trajectory/src/*',
|
'packages/client/ui-trajectory/src/*',
|
||||||
|
'packages/client/ui-question/src/client/QuestionComposer.tsx',
|
||||||
'packages/client/web-react/src/*',
|
'packages/client/web-react/src/*',
|
||||||
'packages/client/runtime/src/*',
|
'packages/client/runtime/src/*',
|
||||||
'packages/client/ui-conversation/src/*',
|
'packages/client/ui-conversation/src/*',
|
||||||
|
|||||||
Reference in New Issue
Block a user