feat(client): typed locale standard seat in the slot framework

Registrations declare a dictionary namespace (locale: NS) and the renderer
synthesizes a typed t prop for the entry's component from the installed
LocaleFace; the seat binding is re-derived per locale revision, so a language
switch hands out fresh t references and memoized consumers re-render through
ordinary shallow comparison. LocaleNamespaceMap is the declare-merge table
(namespace -> dictionary key union); TranslateNS<'ns'> is the
namespace-addressed translate type (namespace keys plus the shared common
vocabulary), carried by the t seat and by the locale service's typed bind.

LocaleService implements the face (lookup ns -> common -> zh -> key,
revision-carrying snapshots with subscriber isolation) and installs it
through the boot-once slots.installLocale seam, mirroring the renderer
install. The typed register(ns, {zh, en}) overload checks each dictionary
against the namespace's key union and requires every shipped locale, so a
missing or extra key and an unbalanced translation are compile errors.
Dictionary registration bumps the face revision without emitting
locale/change — the event now means exactly 'the active locale switched',
so registration-heavy boot cannot storm event listeners.
This commit is contained in:
imccyu
2026-07-30 01:04:56 +08:00
parent 1f242753ec
commit c317fbc489
44 changed files with 925 additions and 189 deletions

View File

@@ -24,6 +24,7 @@
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
@@ -49,6 +50,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",

View File

@@ -4,7 +4,10 @@ import {
Button, IconCheckOutline16, IconChevronLeftOutline14, IconChevronRightOutline14,
IconCloseOutline16, IconEditOutline16, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { PendingQuestion, type QuestionAnswer, type QuestionComposerProps } from './contract/slots.ts'
import {
PendingQuestion,
type QuestionAnswer, type QuestionComposerProps,
} from './contract/slots.ts'
import css from './QuestionComposer.module.css'
interface DraftAnswer {
@@ -52,10 +55,10 @@ 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} />
return <QuestionFlow key={question.key} pending={question} t={props.t} />
}
function QuestionFlow({ pending }: { pending: PendingQuestion }) {
function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<QuestionComposerProps, 't'>) {
const questions = pending.questions
const [index, setIndex] = useState(0)
const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(question => ({
@@ -111,7 +114,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
const missing = values.findIndex(item => !completed(item))
if (missing >= 0) {
setIndex(missing)
setError('请先完成这道问题。')
setError(t('error.incomplete'))
return
}
const answer: QuestionAnswer = {
@@ -136,7 +139,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
const continueFlow = (): void => {
if (!answered(draft)) {
setError('请选择一个选项或填写自定义答案。')
setError(t('error.unanswered'))
return
}
if (index < questions.length - 1) {
@@ -174,28 +177,30 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
<span>{question.multiSelect === true
? parseQuestionTitle(question.question)
: question.question}</span>
{question.multiSelect === true && <span className={css.multiSelectHint}></span>}
{question.multiSelect === true && (
<span className={css.multiSelectHint}>{t('title.multi')}</span>
)}
</h2>
</div>
<div className={css.headerActions}>
<span className={css.progress}>{index + 1} / {questions.length}</span>
<button
type="button" className={css.iconButton} aria-label="上一题"
type="button" className={css.iconButton} aria-label={t('nav.prev')}
disabled={index === 0 || busy !== null}
onClick={() => { setIndex(index - 1); setError(null) }}
>
<IconChevronLeftOutline14 />
</button>
<button
type="button" className={css.iconButton} aria-label="下一题"
type="button" className={css.iconButton} aria-label={t('nav.next')}
disabled={index === questions.length - 1 || busy !== null}
onClick={() => { setIndex(index + 1); setError(null) }}
>
<IconChevronRightOutline14 />
</button>
<button
type="button" className={css.iconButton} aria-label="放弃整组问题"
title="放弃整组问题"
type="button" className={css.iconButton} aria-label={t('nav.cancel')}
title={t('nav.cancel')}
disabled={busy !== null} onClick={cancelFlow}
>
<IconCloseOutline16 />
@@ -230,7 +235,9 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
<span className={css.optionCopy}>
<span className={css.optionLine}>
<span className={css.optionLabel}>{display.label}</span>
{display.recommended && <span className={css.badge}></span>}
{display.recommended && (
<span className={css.badge}>{t('option.recommended')}</span>
)}
{option.description !== undefined && (
<span className={css.description}>{option.description}</span>
)}
@@ -255,7 +262,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
aria-expanded={draft.customOpen}
>
<span className={css.number}><IconEditOutline16 /></span>
<span></span>
<span>{t('option.custom')}</span>
</button>
)}
{draft.customOpen && (
@@ -265,7 +272,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
value={draft.custom}
disabled={busy !== null}
rows={2}
placeholder="输入你的答案"
placeholder={t('custom.placeholder')}
onChange={(event) => {
const value = event.target.value
updateDraft(current => ({
@@ -288,15 +295,15 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
<div className={css.feedback} role="status">{error}</div>
<div className={css.footerActions}>
<Button variant="ghost" size="sm" disabled={busy !== null} onClick={skipQuestion}>
{t('action.skip')}
</Button>
<Button
variant="primary" size="sm"
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
>
{busy === 'answer'
? '正在提交…'
: index === questions.length - 1 ? '提交' : '下一题'}
? t('action.submitting')
: index === questions.length - 1 ? t('action.submit') : t('action.next')}
</Button>
</div>
</footer>

View File

@@ -6,7 +6,7 @@
* cancelled error encoding, receipt checks — lives HERE, with the package
* that consumes it.
*/
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsLocale, 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'
@@ -70,8 +70,9 @@ export class PendingQuestion {
/**
* 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.
* selector result, already narrowed to the question carrier — plus the
* standard locale seat; the carrier plus the domain face above carry the
* whole behavior surface.
*/
export type QuestionComposerProps = PropsRuntime<'conversation.composer'> & { matched: QuestionWait }
export type QuestionComposerProps =
PropsRuntime<'conversation.composer'> & { matched: QuestionWait } & PropsLocale<'question'>

View File

@@ -1,18 +1,32 @@
/**
* 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.
* selector-routed entry of the conversation-declared composer chain, plus the
* `question` dictionaries. 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); copy rides
* the standard locale seat. 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'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { QuestionWait } from './contract/slots.ts'
import { QuestionComposer } from './QuestionComposer.tsx'
import { en, zh, type QuestionKey } from './locales.ts'
export { PendingQuestion } from './contract/slots.ts'
export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
export type { QuestionKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The question composer's copy. */
question: QuestionKey
}
}
/** Dictionary namespace owned by this plugin. */
const NS = 'question'
/**
* Required services (cordis fiber inject). 'conversation' is an ordering
@@ -20,7 +34,7 @@ export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './cont
* declared by ui-conversation's apply, and register() into an undeclared
* slot throws — service waiting orders this apply after the declaring one.
*/
export const inject = ['slots', 'conversation']
export const inject = ['slots', 'conversation', 'locale']
/** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */
function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null {
@@ -28,14 +42,19 @@ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | nu
}
/**
* Client plugin body: register the question composer into the composer chain.
* Zero business face — data and verbs both live on the matched carrier.
* Client plugin body: register the `question` dictionaries and the question
* composer into the composer chain. Zero business face — data and verbs live
* on the matched carrier; t rides the standard locale seat.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const slots = ctx.slots
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-question: dictionaries')
ctx.effect(
() => slots.register({ name: 'conversation.composer', select: selectQuestion }, QuestionComposer),
() => ctx.slots.register(
{ name: 'conversation.composer', select: selectQuestion, locale: NS },
QuestionComposer,
),
'ui-question: composer chain registration',
)
}

View File

@@ -0,0 +1,38 @@
/** `question` namespace dictionaries. */
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'error.incomplete': '请先完成这道问题。',
'error.unanswered': '请选择一个选项或填写自定义答案。',
'title.multi': '可多选',
'nav.prev': '上一题',
'nav.next': '下一题',
'nav.cancel': '放弃整组问题',
'option.recommended': '推荐',
'option.custom': '其他,请填写自定义答案',
'custom.placeholder': '输入你的答案',
'action.skip': '跳过本题',
'action.submitting': '正在提交…',
'action.submit': '提交',
'action.next': '下一题',
} satisfies Record<string, string>
/** The question namespace key union. */
export type QuestionKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en: Record<QuestionKey, string> = {
'error.incomplete': 'Please complete this question first.',
'error.unanswered': 'Please select an option or enter a custom answer.',
'title.multi': 'Multi-select',
'nav.prev': 'Previous question',
'nav.next': 'Next question',
'nav.cancel': 'Dismiss all questions',
'option.recommended': 'Recommended',
'option.custom': 'Other — enter a custom answer',
'custom.placeholder': 'Type your answer',
'action.skip': 'Skip this question',
'action.submitting': 'Submitting…',
'action.submit': 'Submit',
'action.next': 'Next',
}

View File

@@ -9,6 +9,7 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
import { apply, inject } from '../src/client/index.ts'
@@ -24,12 +25,13 @@ async function bench() {
// 'conversation' inject is an ordering edge (the declaring plugin provides
// it after declaring the chain); the bench declares the chain itself.
ctx.provide('conversation', {})
ctx.provide('locale', new LocaleService(ctx))
return { ctx, slots }
}
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slots', 'conversation'])
expect(inject).toEqual(['slots', 'conversation', 'locale'])
})
it('fails loud when no live entry has declared the composer slot', async () => {
@@ -38,6 +40,7 @@ describe('apply', () => {
// Satisfy the ordering inject without declaring the chain: apply must
// then hit the undeclared-slot throw, not sit waiting on the service.
ctx.provide('conversation', {})
ctx.provide('locale', new LocaleService(ctx))
await expect(ctx.plugin({ inject: [...inject], apply }))
.rejects.toThrow(/slot "conversation.composer" is not declared/)
})
@@ -47,8 +50,10 @@ describe('apply', () => {
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.
// The whole behavior surface rides the matched carrier: no business face;
// copy rides the standard locale seat.
expect(entry.inject).toBeUndefined()
expect(entry.locale).toBe('question')
// 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' }

View File

@@ -8,10 +8,11 @@ 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 { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts'
import {
QuestionComposer, parseQuestionTitle, parseRecommendedLabel,
} from '../src/client/QuestionComposer.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
@@ -28,6 +29,9 @@ const kit = {
useProjection: (() => undefined) as never,
useInput: (() => { throw new Error('unused') }) as never,
inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never,
// The seat's key domain is question common; the stub answers from the
// package dictionary and falls back to the key like the real chain.
t: (key => (zh as Record<string, string>)[key] ?? key) as QuestionComposerProps['t'],
}
const QUESTIONS = [

View File

@@ -14,6 +14,9 @@
{
"path": "../connection"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},