feat(web): unify onboarding dialogs
This commit is contained in:
@@ -1,99 +1,16 @@
|
||||
.page {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(640px, calc(100vw - 64px));
|
||||
max-height: 100vh;
|
||||
padding: clamp(104px, 18vh, 156px) 0 40px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 42px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
line-height: 36px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 16px 0 0;
|
||||
font-size: 16px;
|
||||
line-height: 28px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.brand,
|
||||
.title,
|
||||
.description,
|
||||
.actions {
|
||||
animation: credential-enter 280ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
||||
}
|
||||
|
||||
.title { animation-delay: 40ms; }
|
||||
.description { animation-delay: 80ms; }
|
||||
.actions { animation-delay: 120ms; }
|
||||
|
||||
@keyframes credential-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.brand,
|
||||
.title,
|
||||
.description,
|
||||
.actions {
|
||||
animation: none;
|
||||
}
|
||||
.editor {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.page {
|
||||
width: calc(100vw - 40px);
|
||||
padding-top: 64px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column-reverse;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.primary,
|
||||
.later {
|
||||
width: 100%;
|
||||
.editor {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,33 +2,39 @@
|
||||
* Official-DeepSeek first-run step. Readiness comes from the same
|
||||
* provider/settings/credential join as the Models page: any provider the user
|
||||
* can already talk to ends the step, and only a user with none is offered the
|
||||
* official DeepSeek route. The prompt itself only routes to that page's single
|
||||
* credential editor.
|
||||
* official DeepSeek route. The step reuses that page's credential editor in
|
||||
* the onboarding plugin's shared modal, so the key is entered once.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { BrandWordmark, Button, OnboardingSurface } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
|
||||
import { onboardingReadiness } from './store.ts'
|
||||
import { ProviderEditor } from './ProviderEditor.tsx'
|
||||
import type { en } from './locales.ts'
|
||||
import { OnboardingModal } from './OnboardingModal.tsx'
|
||||
import styles from './DeepSeekOnboardingDialog.module.css'
|
||||
|
||||
/** Injected dependencies of {@link DeepSeekOnboardingDialog}. */
|
||||
/** Registration-side dependencies of {@link DeepSeekOnboardingDialog}. */
|
||||
export interface DeepSeekOnboardingInjected {
|
||||
hooks: {
|
||||
/** Shared Models-page join state, bound by the slot renderer. */
|
||||
models: SnapshotStore<ModelsSettingsState>
|
||||
}
|
||||
/** Shared Models-page join controller. */
|
||||
controller: ModelsSettingsStore
|
||||
/** Subscription hook bound to the shared join snapshot. */
|
||||
useSnapshot: SnapshotSelectorHook<ModelsSettingsState>
|
||||
/** Existing wire face reused by the Models credential editor. */
|
||||
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
|
||||
/** Feature copy. */
|
||||
t: (key: keyof typeof en) => string
|
||||
}
|
||||
|
||||
/** Slot owner props plus the feature's injected dependencies. */
|
||||
export type DeepSeekOnboardingDialogProps =
|
||||
PropsRuntime<'settings.onboarding'> & DeepSeekOnboardingInjected
|
||||
PropsRuntime<'settings.onboarding'> & InjectFace<DeepSeekOnboardingInjected>
|
||||
|
||||
/* v8 ignore next 3 -- closed-union defaults only defend future source widening */
|
||||
function assertNever(_value: never): never {
|
||||
@@ -36,16 +42,15 @@ function assertNever(_value: never): never {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt a first-run user to open Models while no provider can serve requests
|
||||
* and the official adapter exists with an unconfigured effective credential.
|
||||
* Prompt a first-run user for the official DeepSeek credential while no
|
||||
* provider can serve requests and that credential is writable.
|
||||
* @param props - settings-shell owner state and Models feature dependencies.
|
||||
* @returns the onboarding page or null when onboarding needs no intervention.
|
||||
* @returns the onboarding modal or null when onboarding needs no intervention.
|
||||
*/
|
||||
export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode {
|
||||
const { complete, openSection, controller, useSnapshot, t } = props
|
||||
const state = useSnapshot(snapshot => snapshot)
|
||||
const { complete, controller, useModels, api, t } = props
|
||||
const state = useModels(snapshot => snapshot)
|
||||
const readiness = onboardingReadiness(state)
|
||||
const titleRef = useRef<HTMLHeadingElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'idle') void controller.load()
|
||||
@@ -59,18 +64,6 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
|
||||
) complete()
|
||||
}, [complete, readiness.kind])
|
||||
|
||||
useEffect(() => {
|
||||
if (readiness.kind === 'credential-missing') titleRef.current?.focus()
|
||||
}, [readiness.kind])
|
||||
|
||||
const openModels = (): void => {
|
||||
complete()
|
||||
openSection('models')
|
||||
}
|
||||
|
||||
// Null covers the still-deciding and nothing-to-do states alike: the
|
||||
// takeover chrome below is part of THIS render, so declining paints and
|
||||
// blocks nothing while the shared join is in flight.
|
||||
switch (readiness.kind) {
|
||||
case 'loading':
|
||||
case 'adapter-absent':
|
||||
@@ -84,28 +77,44 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
|
||||
return assertNever(readiness)
|
||||
}
|
||||
|
||||
const row = state.rows.find(candidate =>
|
||||
candidate.entry.provider === 'deepseek-official'
|
||||
&& candidate.entry.settingsNs === 'llm-deepseek'
|
||||
&& candidate.entry.settingsPath.length === 0)
|
||||
const namespace = state.namespaces.get('llm-deepseek')
|
||||
/* v8 ignore next 2 -- credential-missing is derived only from this exact joined row. */
|
||||
if (row === undefined || namespace === undefined) return null
|
||||
|
||||
const finishCredential = (changed: boolean): void => {
|
||||
if (!changed) {
|
||||
complete()
|
||||
return
|
||||
}
|
||||
void controller.load()
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingSurface>
|
||||
<section className={styles['page']} role="region" aria-labelledby="deepseek-onboarding-title">
|
||||
<div className={styles['brand']} aria-hidden="true"><BrandWordmark size={24} /></div>
|
||||
<h2
|
||||
ref={titleRef}
|
||||
id="deepseek-onboarding-title"
|
||||
className={styles['title']}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{t('onboardingTitle')}
|
||||
</h2>
|
||||
<p className={styles['description']}>{t('onboardingDescription')}</p>
|
||||
<div className={styles['actions']}>
|
||||
<Button variant="ghost" className={styles['later']} onClick={complete}>
|
||||
{t('onboardingLater')}
|
||||
</Button>
|
||||
<Button variant="primary" className={styles['primary']} onClick={openModels}>
|
||||
{t('onboardingGoToSettings')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</OnboardingSurface>
|
||||
<OnboardingModal title={t('onboardingTitle')}>
|
||||
<p className={styles.description}>{t('onboardingDescription')}</p>
|
||||
<div className={styles.editor}>
|
||||
<ProviderEditor
|
||||
provider={row.entry.provider}
|
||||
displayName={row.entry.displayName}
|
||||
namespace={namespace}
|
||||
settingsPath={row.entry.settingsPath}
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={false}
|
||||
hideTitle
|
||||
credentialOnly
|
||||
credentialRequired
|
||||
autoFocusCredential
|
||||
cancelLabel="onboardingLater"
|
||||
submitLabel="onboardingSave"
|
||||
submitBusyLabel="onboardingSaving"
|
||||
onClose={finishCredential}
|
||||
/>
|
||||
</div>
|
||||
</OnboardingModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface EditorFooterProps {
|
||||
submitLabel: keyof typeof en
|
||||
/** Commit label while a commit is in flight. */
|
||||
submitBusyLabel: keyof typeof en
|
||||
/** Dismiss label; defaults to the settings editor copy. */
|
||||
cancelLabel?: keyof typeof en
|
||||
/** Dismiss the card without committing. */
|
||||
onCancel: () => void
|
||||
/** Run the card's commit. */
|
||||
@@ -50,7 +52,7 @@ export function EditorFooter(props: EditorFooterProps): ReactNode {
|
||||
disabled={props.busy}
|
||||
onClick={props.onCancel}
|
||||
>
|
||||
{t('cancel')}
|
||||
{t(props.cancelLabel ?? 'cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
.dialog {
|
||||
width: min(600px, 100%);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 48px);
|
||||
padding: 28px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.content {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/** Shared modal chrome for every step registered by this onboarding plugin. */
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './OnboardingModal.module.css'
|
||||
|
||||
const ignoreImplicitDismiss = (): void => {}
|
||||
|
||||
/**
|
||||
* Render a blocking onboarding dialog and keep the application root inert.
|
||||
* @param props.title - accessible and visible dialog title.
|
||||
* @param props.focusTitle - focus the title when the step has no form control.
|
||||
* @param props.children - step-owned body and actions.
|
||||
* @returns the body-portaled modal.
|
||||
*/
|
||||
export function OnboardingModal({
|
||||
title, focusTitle = false, children,
|
||||
}: {
|
||||
title: string
|
||||
focusTitle?: boolean
|
||||
children: ReactNode
|
||||
}): ReactNode {
|
||||
const titleRef = useRef<HTMLHeadingElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const appRoot = document.getElementById('root')
|
||||
if (appRoot === null) return
|
||||
const previous = appRoot.inert === true
|
||||
appRoot.inert = true
|
||||
return () => { appRoot.inert = previous }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (focusTitle) titleRef.current?.focus()
|
||||
}, [focusTitle])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={title}
|
||||
onClose={ignoreImplicitDismiss}
|
||||
headless
|
||||
className={css['dialog'] ?? ''}
|
||||
>
|
||||
<div className={css.content}>
|
||||
<h2 ref={titleRef} className={css.title} tabIndex={focusTitle ? -1 : undefined}>{title}</h2>
|
||||
<div className={css.body}>{children}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -69,6 +69,18 @@ export interface ProviderEditorProps {
|
||||
t: (key: keyof typeof en) => string
|
||||
/** Disable writes (read-only settings provider). */
|
||||
readOnly: boolean
|
||||
/** Render only the credential field and actions, without provider settings. */
|
||||
credentialOnly?: boolean
|
||||
/** Require a newly entered credential before this editor can submit. */
|
||||
credentialRequired?: boolean
|
||||
/** Give the credential field initial focus when this editor mounts. */
|
||||
autoFocusCredential?: boolean
|
||||
/** Override the dismiss action copy. */
|
||||
cancelLabel?: keyof typeof en
|
||||
/** Override the idle commit action copy. */
|
||||
submitLabel?: keyof typeof en
|
||||
/** Override the in-flight commit action copy. */
|
||||
submitBusyLabel?: keyof typeof en
|
||||
/** Close the editor; `changed` reports whether an Apply committed. */
|
||||
onClose: (changed: boolean) => void
|
||||
}
|
||||
@@ -198,6 +210,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
// as "no key supplied" rather than as a key — that is how a card whose
|
||||
// provider already has a stored key is edited without re-entering it.
|
||||
const keyValue = keyDraft.trim()
|
||||
const credentialRequiredFailure = props.credentialRequired === true
|
||||
&& keyDraft.length > 0 && keyValue.length === 0
|
||||
? 'keyRequired' as const
|
||||
: undefined
|
||||
const shownKeyFailure = credentialRequiredFailure ?? keyFailure
|
||||
// What the form currently shows, which is what an interrogation must ask:
|
||||
// an edited-but-unsaved endpoint, and a key typed but not yet stored.
|
||||
const probeApi = stringAt(draft, 'api') ?? stringAt(fallback, 'api')
|
||||
@@ -225,7 +242,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
&& stringAt(fallback, 'apiKeyEnv') === undefined && keyValue.length > 0
|
||||
? setPath(draft, ['apiKeyEnv'], keyRef)
|
||||
: draft
|
||||
{
|
||||
if (props.credentialOnly !== true) {
|
||||
// The same checker gates the submit button, so a card cannot reach this
|
||||
// with a bad row; it stays because the schema check below would refuse
|
||||
// the write with a message naming a path instead of the row, and because
|
||||
@@ -237,7 +254,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
}
|
||||
}
|
||||
/* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
|
||||
if (node !== undefined && settingsPath.length === 0) {
|
||||
if (props.credentialOnly !== true && node !== undefined && settingsPath.length === 0) {
|
||||
const sectionError = validateDraft(node, next)
|
||||
if (sectionError !== undefined) return sectionError
|
||||
}
|
||||
@@ -245,9 +262,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
&& fallback === undefined
|
||||
&& committedOriginal === undefined
|
||||
&& Object.keys(next).length === 0
|
||||
const ops: SettingsPathOpView[] = materializesNativeProfile
|
||||
? [{ op: 'set', path: [...settingsPath], value: {} }]
|
||||
: pathOps(settingsPath, committedOriginal, next)
|
||||
const ops: SettingsPathOpView[] = props.credentialOnly === true
|
||||
? []
|
||||
: materializesNativeProfile
|
||||
? [{ op: 'set', path: [...settingsPath], value: {} }]
|
||||
: pathOps(settingsPath, committedOriginal, next)
|
||||
if (ops.length > 0) {
|
||||
const response = await api.settings.mutate({ ns, ops, expectedRevision })
|
||||
if (!response.result.ok) {
|
||||
@@ -324,7 +343,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
const defaultMaxTokens = getPath(fallback, ['maxTokens'])
|
||||
const keyPlaceholder = keyLocked
|
||||
? t('keyEnvLocked')
|
||||
: keyState?.configured === true
|
||||
: keyState?.configured === true && props.credentialRequired !== true
|
||||
? t('keyStored')
|
||||
: family === 'pi-ai' ? t('keyPlaceholderNative') : t('keyPlaceholder')
|
||||
/** What both family editors take: the rows, whose layer owns them, and the two writes. */
|
||||
@@ -349,12 +368,15 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
value={keyDraft}
|
||||
placeholder={keyPlaceholder}
|
||||
aria-label={t('keyInput')}
|
||||
aria-invalid={shownKeyFailure !== undefined}
|
||||
required={props.credentialRequired === true}
|
||||
autoFocus={props.autoFocusCredential === true}
|
||||
disabled={disabled || keyLocked}
|
||||
onChange={(event) => { setKeyDraft(event.target.value) }}
|
||||
/>
|
||||
{keyFailure === undefined ? null : <p className={styles['error']}>{t(keyFailure)}</p>}
|
||||
{shownKeyFailure === undefined ? null : <p className={styles['error']}>{t(shownKeyFailure)}</p>}
|
||||
</div>
|
||||
<details className={styles['customized']}>
|
||||
{props.credentialOnly === true ? null : <details className={styles['customized']}>
|
||||
<summary className={styles['customizedSummary']}>{t('customized')}</summary>
|
||||
<div className={styles['customizedBody']}>
|
||||
{/* The name and the protocol are the create card's two remaining
|
||||
@@ -440,13 +462,13 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
)
|
||||
: <ModelListEditor {...catalogProps} probe={probe} probeBlocked={keyFailure} api={api} />}
|
||||
</div>
|
||||
</details>
|
||||
</details>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles['editor']}>
|
||||
<div className={props.credentialOnly === true ? styles['addBlock'] : styles['editor']}>
|
||||
{props.hideTitle === true
|
||||
? null
|
||||
: (
|
||||
@@ -461,7 +483,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
? <p className={styles['advancedHint']}>{`${t('advancedHint')} (${namespace.ns})`}</p>
|
||||
: curatedFields(layout)}
|
||||
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
|
||||
{modelFailure === undefined
|
||||
{props.credentialOnly === true || modelFailure === undefined
|
||||
? null
|
||||
: (
|
||||
<p className={styles['advancedHint']}>
|
||||
@@ -471,10 +493,13 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
<EditorFooter
|
||||
t={t}
|
||||
busy={busy}
|
||||
submitDisabled={disabled || layout === 'unknown' || modelFailure !== undefined
|
||||
|| keyFailure !== undefined}
|
||||
submitLabel="apply"
|
||||
submitBusyLabel="applying"
|
||||
submitDisabled={disabled || layout === 'unknown'
|
||||
|| (props.credentialOnly !== true && modelFailure !== undefined)
|
||||
|| shownKeyFailure !== undefined
|
||||
|| (props.credentialRequired === true && keyValue.length === 0)}
|
||||
submitLabel={props.submitLabel ?? 'apply'}
|
||||
submitBusyLabel={props.submitBusyLabel ?? 'applying'}
|
||||
{...props.cancelLabel === undefined ? {} : { cancelLabel: props.cancelLabel }}
|
||||
onCancel={() => { props.onClose(false) }}
|
||||
onSubmit={() => { void apply() }}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
.copy {
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.copy p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.copy p + p {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 16px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.primary {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/** Product-wide, versioned internal-testing notice. */
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { WelcomeNoticeState, WelcomeNoticeStore } from './welcome-store.ts'
|
||||
import type { en } from './locales.ts'
|
||||
import { OnboardingModal } from './OnboardingModal.tsx'
|
||||
import css from './WelcomeNotice.module.css'
|
||||
|
||||
/** Registration-side dependencies of {@link WelcomeNotice}. */
|
||||
export interface WelcomeNoticeInjected {
|
||||
hooks: {
|
||||
/** Durable or process-local acknowledgement state. */
|
||||
welcome: SnapshotStore<WelcomeNoticeState>
|
||||
}
|
||||
/** Welcome acknowledgement controller. */
|
||||
controller: WelcomeNoticeStore
|
||||
/** Onboarding copy. */
|
||||
t: (key: keyof typeof en) => string
|
||||
}
|
||||
|
||||
/** Coordinator owner props plus this step's injected face. */
|
||||
export type WelcomeNoticeProps =
|
||||
PropsRuntime<'settings.onboarding'> & InjectFace<WelcomeNoticeInjected>
|
||||
|
||||
/**
|
||||
* Render the current notice until its exact copy version is acknowledged.
|
||||
* @param props - settings-shell owner state and welcome dependencies.
|
||||
* @returns the welcome modal or null while the step decides not to show.
|
||||
*/
|
||||
export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode {
|
||||
const { complete, controller, useWelcome, t } = props
|
||||
const state = useWelcome(snapshot => snapshot)
|
||||
const finished = useRef(false)
|
||||
const finish = useCallback((): void => {
|
||||
if (finished.current) return
|
||||
finished.current = true
|
||||
complete()
|
||||
}, [complete])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'idle') void controller.load()
|
||||
}, [controller, state.status])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.acknowledged) finish()
|
||||
}, [finish, state.acknowledged])
|
||||
|
||||
if (state.status === 'idle' || state.status === 'loading' || state.acknowledged) return null
|
||||
|
||||
const acknowledge = async (): Promise<void> => {
|
||||
if (await controller.acknowledge()) finish()
|
||||
}
|
||||
const paragraphs = t('welcomeBody').split('\n\n')
|
||||
|
||||
return (
|
||||
<OnboardingModal title={t('welcomeTitle')} focusTitle>
|
||||
<div className={css.copy}>
|
||||
{paragraphs.map(paragraph => <p key={paragraph}>{paragraph}</p>)}
|
||||
</div>
|
||||
{state.error === null ? null : <p className={css.error} role="alert">{t('welcomeError')}</p>}
|
||||
<div className={css.actions}>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.primary}
|
||||
disabled={state.status === 'saving'}
|
||||
onClick={() => { void acknowledge() }}
|
||||
>
|
||||
{t('welcomeContinue')}
|
||||
</Button>
|
||||
</div>
|
||||
</OnboardingModal>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Models settings plugin, browser half. Registers the `models` nav entry and
|
||||
* official-DeepSeek first-run overlay into shell-declared slots. Both consume
|
||||
* one provider/settings/credential join; the overlay routes missing-key users
|
||||
* to the full page's single credential editor. Export discipline:
|
||||
* Models settings and product-onboarding plugin, browser half. It registers
|
||||
* the Models page plus the ordered internal-testing and official-DeepSeek
|
||||
* onboarding dialogs, whose UI shares this package's modal wrapper. The Host
|
||||
* settings and credential contracts stay behind their existing wire APIs.
|
||||
* Export discipline:
|
||||
* packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -19,15 +20,19 @@ import { ModelsSection } from './ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected } from './ModelsSection.tsx'
|
||||
import { DeepSeekOnboardingDialog } from './DeepSeekOnboardingDialog.tsx'
|
||||
import type { DeepSeekOnboardingInjected } from './DeepSeekOnboardingDialog.tsx'
|
||||
import { WelcomeNotice } from './WelcomeNotice.tsx'
|
||||
import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx'
|
||||
import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts'
|
||||
import { ModelsSettingsStore } from './store.ts'
|
||||
import { en, zh, type ModelsKey } from './locales.ts'
|
||||
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts'
|
||||
|
||||
export type { ModelsSectionInjected, ModelsSectionProps } from './ModelsSection.tsx'
|
||||
export type { ModelsKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The Models page + onboarding overlay copy. */
|
||||
/** The Models page + product-onboarding copy. */
|
||||
'settings.models': ModelsKey
|
||||
}
|
||||
}
|
||||
@@ -74,21 +79,38 @@ export function apply(ctx: ClientContext): void {
|
||||
api: connection.api,
|
||||
t,
|
||||
})
|
||||
const onboardingInjected = (): DeepSeekOnboardingInjected => ({
|
||||
const deepSeekOnboardingInjected = (): DeepSeekOnboardingInjected => ({
|
||||
controller,
|
||||
useSnapshot,
|
||||
hooks: { models: controller.store },
|
||||
api: connection.api,
|
||||
t,
|
||||
})
|
||||
const welcomeController = new WelcomeNoticeStore(
|
||||
connection.api,
|
||||
connection.isLoopback ? 'host' : 'memory',
|
||||
)
|
||||
const welcomeInjected = (): WelcomeNoticeInjected => ({
|
||||
controller: welcomeController,
|
||||
hooks: { welcome: welcomeController.store },
|
||||
t,
|
||||
})
|
||||
|
||||
// Pushed invalidations converge every open surface without polling: any
|
||||
// settings/credentials/topology change refetches once the page loaded.
|
||||
ctx.effect(() => {
|
||||
const refresh = (): void => { refreshIfLoaded(controller) }
|
||||
const refreshModels = (): void => { refreshIfLoaded(controller) }
|
||||
const refreshAll = (): void => {
|
||||
refreshModels()
|
||||
refreshWelcomeIfLoaded(welcomeController)
|
||||
}
|
||||
const disposers = [
|
||||
ctx.remote.$on('settings/document-updated', refresh),
|
||||
ctx.remote.$on('credentials/updated', refresh),
|
||||
ctx.remote.$on('llm/adapters-updated', refresh),
|
||||
ctx.on('connection/reset', refresh),
|
||||
ctx.remote.$on('settings/document-updated', (ns) => {
|
||||
refreshModels()
|
||||
if (ns === WELCOME_NOTICE_SETTINGS_NAMESPACE) refreshWelcomeIfLoaded(welcomeController)
|
||||
}),
|
||||
ctx.remote.$on('credentials/updated', refreshModels),
|
||||
ctx.remote.$on('llm/adapters-updated', refreshModels),
|
||||
ctx.on('connection/reset', refreshAll),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-settings-models: pushed invalidations')
|
||||
@@ -100,10 +122,16 @@ export function apply(ctx: ClientContext): void {
|
||||
label: () => t('nav'),
|
||||
inject: injected,
|
||||
}, ModelsSection))
|
||||
ctx.slots.inject('settings.onboarding', () => ctx.slots.register({
|
||||
name: 'settings.onboarding',
|
||||
id: 'welcome-notice',
|
||||
order: -100,
|
||||
inject: welcomeInjected,
|
||||
}, WelcomeNotice))
|
||||
ctx.slots.inject('settings.onboarding', () => ctx.slots.register({
|
||||
name: 'settings.onboarding',
|
||||
id: 'deepseek-official',
|
||||
order: 0,
|
||||
inject: onboardingInjected,
|
||||
inject: deepSeekOnboardingInjected,
|
||||
}, DeepSeekOnboardingDialog))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Copy dictionaries for the Models settings section. */
|
||||
|
||||
import { WELCOME_NOTICE_COPY } from '../onboarding-copy.ts'
|
||||
|
||||
/** English strings (the key-set source of truth for this pair). */
|
||||
export const en = {
|
||||
nav: 'Models',
|
||||
@@ -85,10 +87,16 @@ export const en = {
|
||||
customNeedsModels: 'A custom provider needs at least one model.',
|
||||
create: 'Create provider',
|
||||
creating: 'Creating\u2026',
|
||||
welcomeTitle: WELCOME_NOTICE_COPY.en.title,
|
||||
welcomeBody: WELCOME_NOTICE_COPY.en.body,
|
||||
welcomeContinue: WELCOME_NOTICE_COPY.en.continueLabel,
|
||||
welcomeError: 'The acknowledgement could not be saved. Please try again.',
|
||||
onboardingTitle: 'Add an API key to get started',
|
||||
onboardingDescription: 'Configure the official DeepSeek provider to start building.',
|
||||
onboardingGoToSettings: 'Go to settings',
|
||||
onboardingLater: 'Configure later',
|
||||
onboardingSave: 'Save and continue',
|
||||
onboardingSaving: 'Saving…',
|
||||
keyRequired: 'Enter an API key to continue.',
|
||||
}
|
||||
|
||||
/** The settings.models namespace key union. */
|
||||
@@ -179,8 +187,14 @@ export const zh: typeof en = {
|
||||
customNeedsModels: '自定义提供方至少需要一个模型。',
|
||||
create: '创建提供方',
|
||||
creating: '创建中\u2026',
|
||||
welcomeTitle: WELCOME_NOTICE_COPY.zh.title,
|
||||
welcomeBody: WELCOME_NOTICE_COPY.zh.body,
|
||||
welcomeContinue: WELCOME_NOTICE_COPY.zh.continueLabel,
|
||||
welcomeError: '暂时无法保存确认状态,请重试。',
|
||||
onboardingTitle: '添加一个 API Key 开始使用',
|
||||
onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。',
|
||||
onboardingGoToSettings: '前往配置',
|
||||
onboardingLater: '稍后配置',
|
||||
onboardingSave: '保存并继续',
|
||||
onboardingSaving: '保存中…',
|
||||
keyRequired: '请输入 API 密钥后继续。',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/** Welcome-notice state, durable when the browser may use Host settings. */
|
||||
|
||||
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
} from '../onboarding-copy.ts'
|
||||
|
||||
/** State rendered by the welcome step. */
|
||||
export interface WelcomeNoticeState {
|
||||
status: 'idle' | 'loading' | 'ready' | 'saving' | 'error'
|
||||
acknowledged: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function acknowledgementOf(view: SettingsNamespaceView): string | undefined {
|
||||
if (typeof view.value !== 'object' || view.value === null) return undefined
|
||||
const value = (view.value as Record<string, unknown>)[WELCOME_NOTICE_ACK_FIELD]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
/** Coordinates durable Host acknowledgement or a process-local remote fallback. */
|
||||
export class WelcomeNoticeStore {
|
||||
/** uSES-safe state source shared by the registered welcome step. */
|
||||
readonly store: SnapshotStore<WelcomeNoticeState> = createSnapshotStore({
|
||||
status: 'idle', acknowledged: false, error: null,
|
||||
})
|
||||
|
||||
private generation = 0
|
||||
|
||||
/**
|
||||
* @param api - settings wire face used for durable reads and writes.
|
||||
* @param persistence - remote browsers use memory because settings is loopback-only.
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: Pick<IApiClient, 'settings'>,
|
||||
private readonly persistence: 'host' | 'memory' = 'host',
|
||||
) {}
|
||||
|
||||
/** Load the acknowledgement from Host settings or initialize process-local state. */
|
||||
async load(): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
if (this.persistence === 'memory') {
|
||||
this.store.update((state) => { state.status = 'ready'; state.error = null })
|
||||
return
|
||||
}
|
||||
this.store.update((state) => { state.status = 'loading'; state.error = null })
|
||||
try {
|
||||
const response = await this.api.settings.describe({})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
const view = response.result.value.namespaces.find(
|
||||
candidate => candidate.ns === WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
)
|
||||
if (view === undefined) throw new Error('welcome acknowledgement settings are unavailable')
|
||||
if (generation !== this.generation) return
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = acknowledgementOf(view) === WELCOME_NOTICE_VERSION
|
||||
state.error = null
|
||||
})
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
this.store.update((state) => {
|
||||
state.status = 'error'
|
||||
state.acknowledged = false
|
||||
state.error = messageOf(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist this copy version, or advance only this process for a remote browser.
|
||||
* @returns true when the selected persistence mode accepted the acknowledgement.
|
||||
*/
|
||||
async acknowledge(): Promise<boolean> {
|
||||
const generation = ++this.generation
|
||||
if (this.persistence === 'memory') {
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = true
|
||||
state.error = null
|
||||
})
|
||||
return true
|
||||
}
|
||||
this.store.update((state) => { state.status = 'saving'; state.error = null })
|
||||
try {
|
||||
const response = await this.api.settings.mutate({
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
|
||||
})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
if (generation === this.generation) {
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = true
|
||||
state.error = null
|
||||
})
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
if (generation === this.generation) {
|
||||
this.store.update((state) => {
|
||||
state.status = 'error'
|
||||
state.acknowledged = false
|
||||
state.error = messageOf(error)
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh only after welcome state has left idle. A memory-mode load retains
|
||||
* acknowledgement so reconnect does not reopen a process-local notice.
|
||||
* @param controller - welcome state owner whose current status decides whether to load.
|
||||
*/
|
||||
export function refreshWelcomeIfLoaded(controller: WelcomeNoticeStore): void {
|
||||
if (controller.store.getSnapshot().status === 'idle') return
|
||||
void controller.load()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Durable settings namespace for product-wide GUI onboarding facts. */
|
||||
export const WELCOME_NOTICE_SETTINGS_NAMESPACE = 'ui-onboarding'
|
||||
|
||||
/** Field storing the last welcome notice version the user acknowledged. */
|
||||
export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion'
|
||||
|
||||
/**
|
||||
* Bump only when the notice changes materially and every user should see it
|
||||
* again. The acknowledgement is compared for exact equality.
|
||||
*/
|
||||
export const WELCOME_NOTICE_VERSION = '2026-08-13.1'
|
||||
|
||||
/** The complete editable internal-testing notice in both supported GUI locales. */
|
||||
export const WELCOME_NOTICE_COPY = {
|
||||
zh: {
|
||||
title: '内测声明',
|
||||
body: 'DeepSeek Harness 目前的 0.1 版本仍处在面向 Harness 开发者进行测试的阶段,还有许多地方需要持续改进和打磨,希望听取广大开发者的反馈建议。预计 DeepSeek Harness 的核心插件以及基础 API 都会在接下来的一段时间内快速迭代、持续演化。\n\n我们期待与全球开发者一起,在开源、开放、可复用、可组合的基础设施之上,共同探索智能上限。欢迎全球 Harness 开发者加入 DSH 插件生态。',
|
||||
continueLabel: '继续',
|
||||
},
|
||||
en: {
|
||||
title: '内测声明',
|
||||
body: 'DeepSeek Harness 目前的 0.1 版本仍处在面向 Harness 开发者进行测试的阶段,还有许多地方需要持续改进和打磨,希望听取广大开发者的反馈建议。预计 DeepSeek Harness 的核心插件以及基础 API 都会在接下来的一段时间内快速迭代、持续演化。\n\n我们期待与全球开发者一起,在开源、开放、可复用、可组合的基础设施之上,共同探索智能上限。欢迎全球 Harness 开发者加入 DSH 插件生态。',
|
||||
continueLabel: '继续',
|
||||
},
|
||||
} as const
|
||||
Reference in New Issue
Block a user