414 lines
16 KiB
TypeScript
414 lines
16 KiB
TypeScript
/**
|
|
* Models settings section: the provider rows joined from the configurable
|
|
* directory, settings namespaces, and credential states, with one editor
|
|
* card at a time. Rows expose only confirmed API-key state through accessible
|
|
* solid configured or missing dots. A whole-section provider without a
|
|
* configured key (the unconfigured DeepSeek posture) renders as its open setup
|
|
* card instead of a row; the add flow is a card carrying the dormant-provider
|
|
* select. Every mutation writes through the wire, while a provider removal first requires
|
|
* confirmation; the page re-renders from pushed invalidations or the
|
|
* post-apply reload.
|
|
*/
|
|
|
|
import { useState } from 'react'
|
|
import type { ReactNode } from 'react'
|
|
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
|
import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
|
import { deriveKeyRef, messageOf } from './store.ts'
|
|
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
|
|
import { ProviderEditor } from './ProviderEditor.tsx'
|
|
import type { en } from './locales.ts'
|
|
import styles from './ModelsSection.module.css'
|
|
|
|
/** Injected dependencies of {@link ModelsSection} (slot `inject`). */
|
|
export interface ModelsSectionInjected {
|
|
/** The page store (loaded on mount, refreshed on pushed invalidations). */
|
|
controller: ModelsSettingsStore
|
|
/** uSES subscription hook bound to the store. */
|
|
useSnapshot: SnapshotSelectorHook<ModelsSettingsState>
|
|
/** Wire faces the editor writes through. */
|
|
api: Pick<IApiClient, 'settings' | 'credentials'>
|
|
/** Section copy. */
|
|
t: (key: keyof typeof en) => string
|
|
}
|
|
|
|
/**
|
|
* Props delivered by the slot outlet: the inject face spread flat (the
|
|
* renderer erases the share boundary at the render call).
|
|
*/
|
|
export type ModelsSectionProps = Partial<ModelsSectionInjected>
|
|
|
|
/** Provider identity shared by row actions and confirmation copy. */
|
|
export interface ProviderIdentity {
|
|
/** Stable provider route id. */
|
|
provider: string
|
|
/** Human-facing provider name. */
|
|
displayName: string
|
|
}
|
|
|
|
/** One existing row or dormant directory entry addressed by an editor action. */
|
|
interface EditorTarget extends ProviderIdentity {
|
|
settingsNs: string
|
|
settingsPath: readonly string[]
|
|
/** Writable credential identified under this page's conventional reference. */
|
|
credentialRef?: string
|
|
}
|
|
|
|
/**
|
|
* Remove one user-added provider and its page-managed credential. Credential
|
|
* removal comes first so a second-step failure leaves the provider row visible
|
|
* and the whole operation safely retryable; both unsets are idempotent.
|
|
* The settings removal names the profile rather than rebuilding its redacted
|
|
* namespace, which would drop literal secrets stored elsewhere.
|
|
* @param api - settings and credential wire faces.
|
|
* @param controller - the page store to refresh.
|
|
* @param target - the provider's settings address and optional managed credential.
|
|
* @returns the failure message, or undefined once the write and reload landed.
|
|
*/
|
|
export async function removeProviderProfile(
|
|
api: Pick<IApiClient, 'settings' | 'credentials'>,
|
|
controller: ModelsSettingsStore,
|
|
target: { settingsNs: string; settingsPath: readonly string[]; credentialRef?: string },
|
|
): Promise<string | undefined> {
|
|
try {
|
|
if (target.credentialRef !== undefined) {
|
|
const credential = await api.credentials.unset({ ref: target.credentialRef })
|
|
if (!credential.result.ok) return credential.result.error.message
|
|
}
|
|
const response = await api.settings.mutate({
|
|
ns: target.settingsNs,
|
|
ops: [{ op: 'unset', path: [...target.settingsPath] }],
|
|
})
|
|
if (!response.result.ok) return response.result.error.message
|
|
} catch (error) {
|
|
// The transport rejected rather than answering; the caller must be able
|
|
// to retry the idempotent operation instead of the row silently staying.
|
|
return messageOf(error)
|
|
}
|
|
await controller.load()
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Whether a whole-section provider still needs its first key: nothing marks
|
|
* the credential configured and no literal `apiKey` is stored, so the page
|
|
* opens the setup card instead of showing a row.
|
|
* @param row - the joined provider row.
|
|
* @returns whether to render the setup card.
|
|
*/
|
|
export function needsSetup(row: ProviderRow): boolean {
|
|
if (row.entry.settingsPath.length > 0) return false
|
|
if (row.credential?.configured === true) return false
|
|
return !row.literalApiKeyConfigured
|
|
}
|
|
|
|
function targetOf(row: ProviderRow): EditorTarget {
|
|
const managedRef = deriveKeyRef(row.entry.provider)
|
|
const credentialRef = row.apiKeyEnv === managedRef
|
|
&& row.credential?.configured === true
|
|
&& row.credential.writable
|
|
? managedRef
|
|
: undefined
|
|
return {
|
|
provider: row.entry.provider,
|
|
displayName: row.entry.displayName,
|
|
settingsNs: row.entry.settingsNs,
|
|
settingsPath: row.entry.settingsPath,
|
|
...credentialRef === undefined ? {} : { credentialRef },
|
|
}
|
|
}
|
|
|
|
/** Stable visible and accessible identity for one provider target. */
|
|
export function providerTargetLabel(target: ProviderIdentity): string {
|
|
return target.provider === target.displayName
|
|
? target.provider
|
|
: `${target.displayName} (${target.provider})`
|
|
}
|
|
|
|
/** Replace the one provider placeholder in localized destructive-action copy. */
|
|
export function providerCopy(template: string, target: ProviderIdentity): string {
|
|
return template.replace('{provider}', () => providerTargetLabel(target))
|
|
}
|
|
|
|
/**
|
|
* Render the Models section content column.
|
|
* @param props - slot-delivered injected dependencies.
|
|
* @returns the section, or null while the shell has not injected yet.
|
|
*/
|
|
export function ModelsSection(props: ModelsSectionProps): ReactNode {
|
|
const { controller, useSnapshot, api, t } = props
|
|
if (controller === undefined || useSnapshot === undefined || api === undefined || t === undefined) return null
|
|
return <Loaded injected={{ controller, useSnapshot, api, t }} />
|
|
}
|
|
|
|
function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
|
const { controller, api, t } = injected
|
|
const state = injected.useSnapshot(snapshot => snapshot)
|
|
const [editing, setEditing] = useState<EditorTarget | undefined>(undefined)
|
|
const [adding, setAdding] = useState(false)
|
|
const [deleteTarget, setDeleteTarget] = useState<EditorTarget | undefined>(undefined)
|
|
const [deleting, setDeleting] = useState(false)
|
|
const [deleteFailure, setDeleteFailure] = useState<string | undefined>(undefined)
|
|
const [savedTarget, setSavedTarget] = useState<ProviderIdentity | undefined>(undefined)
|
|
|
|
const closeEditor = (changed: boolean, target: ProviderIdentity): void => {
|
|
setEditing(undefined)
|
|
setAdding(false)
|
|
if (changed) {
|
|
setSavedTarget(target)
|
|
void controller.load()
|
|
}
|
|
}
|
|
|
|
const closeDelete = (): void => {
|
|
if (deleting) return
|
|
setDeleteTarget(undefined)
|
|
setDeleteFailure(undefined)
|
|
}
|
|
|
|
const confirmDelete = (): void => {
|
|
/* v8 ignore next -- the action only renders with a target and is disabled while a deletion is pending */
|
|
if (deleteTarget === undefined || deleting) return
|
|
setDeleting(true)
|
|
setDeleteFailure(undefined)
|
|
void removeProviderProfile(api, controller, deleteTarget)
|
|
.then((failure) => {
|
|
if (failure !== undefined) {
|
|
setDeleteFailure(failure)
|
|
return
|
|
}
|
|
setDeleteTarget(undefined)
|
|
})
|
|
.finally(() => { setDeleting(false) })
|
|
}
|
|
|
|
if (state.status === 'idle') void controller.load()
|
|
if (state.status === 'error') {
|
|
/* v8 ignore next -- an error status always carries text; the fallback satisfies the nullable type */
|
|
const errorText = state.error ?? ''
|
|
return (
|
|
<div className={styles['section']}>
|
|
<p className={styles['error']}>{`${t('loadFailed')}: ${errorText}`}</p>
|
|
<button type="button" className={styles['secondaryButton']} onClick={() => { void controller.load() }}>
|
|
{t('retry')}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const configured = state.rows.filter(row => row.configured)
|
|
const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '')
|
|
const addTarget = adding ? editing : undefined
|
|
const addNamespace = addTarget === undefined ? undefined : state.namespaces.get(addTarget.settingsNs)
|
|
|
|
return (
|
|
<div className={styles['section']}>
|
|
<h2 className={styles['title']}>{t('title')}</h2>
|
|
<p className={styles['intro']}>{t('intro')}</p>
|
|
{!state.writable && state.status === 'ready' ? <p className={styles['notice']}>{t('readOnly')}</p> : null}
|
|
{savedTarget === undefined
|
|
? null
|
|
: (
|
|
<p className={styles['savedNotice']} role="status" aria-live="polite">
|
|
{providerCopy(t('savedProvider'), savedTarget)}
|
|
</p>
|
|
)}
|
|
<ul className={styles['rows']}>
|
|
{configured.map((row) => {
|
|
const target = targetOf(row)
|
|
const namespace = state.namespaces.get(target.settingsNs)
|
|
/* v8 ignore next -- the join marks a row configured only when its namespace resolved */
|
|
if (namespace === undefined) return null
|
|
if (needsSetup(row)) {
|
|
// First-run posture: the provider exists but has no key — the
|
|
// setup card IS its presence on the page.
|
|
return (
|
|
<li key={row.entry.provider} className={styles['setupCard']}>
|
|
<ProviderEditor
|
|
provider={target.provider}
|
|
displayName={target.displayName}
|
|
namespace={namespace}
|
|
settingsPath={target.settingsPath}
|
|
api={api}
|
|
t={t}
|
|
readOnly={!state.writable}
|
|
onClose={(changed) => { closeEditor(changed, target) }}
|
|
/>
|
|
</li>
|
|
)
|
|
}
|
|
const open = !adding && editing?.provider === row.entry.provider
|
|
const credentialConfigured = row.literalApiKeyConfigured || row.credential?.configured === true
|
|
const credentialMissing = !credentialConfigured
|
|
&& row.apiKeyEnv !== undefined
|
|
&& row.credential?.configured === false
|
|
return (
|
|
<li key={row.entry.provider} className={styles['rowCard']}>
|
|
<div className={styles['rowHead']}>
|
|
<span className={styles['rowIdentity']}>
|
|
<span className={styles['rowName']}>{row.entry.displayName}</span>
|
|
{credentialConfigured
|
|
? (
|
|
<span
|
|
className={`${styles['credentialDot']} ${styles['credentialDotConfigured']}`}
|
|
role="img"
|
|
aria-label={t('credentialConfigured')}
|
|
title={t('credentialConfigured')}
|
|
/>
|
|
)
|
|
: credentialMissing
|
|
? (
|
|
<span
|
|
className={`${styles['credentialDot']} ${styles['credentialDotMissing']}`}
|
|
role="img"
|
|
aria-label={t('credentialMissing')}
|
|
title={t('credentialMissing')}
|
|
/>
|
|
)
|
|
: null}
|
|
</span>
|
|
<span className={styles['rowActions']}>
|
|
<button
|
|
type="button"
|
|
className={styles['secondaryButton']}
|
|
aria-label={providerCopy(t('editProvider'), target)}
|
|
onClick={() => {
|
|
setSavedTarget(undefined)
|
|
setAdding(false)
|
|
setEditing(open ? undefined : target)
|
|
}}
|
|
>
|
|
{t('edit')}
|
|
</button>
|
|
{row.removable
|
|
? (
|
|
<button
|
|
type="button"
|
|
className={styles['dangerButton']}
|
|
aria-label={providerCopy(t('removeProvider'), target)}
|
|
disabled={!state.writable}
|
|
onClick={() => {
|
|
setSavedTarget(undefined)
|
|
setDeleteFailure(undefined)
|
|
setDeleteTarget(target)
|
|
}}
|
|
>
|
|
{t('remove')}
|
|
</button>
|
|
)
|
|
: null}
|
|
</span>
|
|
</div>
|
|
{open
|
|
? (
|
|
<ProviderEditor
|
|
provider={target.provider}
|
|
displayName={target.displayName}
|
|
namespace={namespace}
|
|
settingsPath={target.settingsPath}
|
|
api={api}
|
|
t={t}
|
|
readOnly={!state.writable}
|
|
onClose={(changed) => { closeEditor(changed, target) }}
|
|
/>
|
|
)
|
|
: null}
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
<div className={styles['addBlock']}>
|
|
{addTarget !== undefined && addNamespace !== undefined
|
|
? (
|
|
<div className={styles['addCard']}>
|
|
<div className={styles['field']}>
|
|
<span className={styles['fieldLabel']}>{t('provider')}</span>
|
|
<select
|
|
className={`${styles['input']} ${styles['selectInput']}`}
|
|
value={addTarget.provider}
|
|
aria-label={t('provider')}
|
|
onChange={(event) => {
|
|
const row = addable.find(candidate => candidate.entry.provider === event.target.value)
|
|
/* v8 ignore next -- the select only lists addable rows */
|
|
if (row === undefined) return
|
|
setEditing(targetOf(row))
|
|
}}
|
|
>
|
|
{addable.map(row => (
|
|
<option key={row.entry.provider} value={row.entry.provider}>{row.entry.displayName}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<ProviderEditor
|
|
key={addTarget.provider}
|
|
provider={addTarget.provider}
|
|
displayName={addTarget.displayName}
|
|
hideTitle
|
|
namespace={addNamespace}
|
|
settingsPath={addTarget.settingsPath}
|
|
api={api}
|
|
t={t}
|
|
readOnly={!state.writable}
|
|
onClose={(changed) => { closeEditor(changed, addTarget) }}
|
|
/>
|
|
</div>
|
|
)
|
|
: (
|
|
<button
|
|
type="button"
|
|
className={styles['addButton']}
|
|
disabled={addable.length === 0 || !state.writable}
|
|
onClick={() => {
|
|
const first = addable[0]
|
|
/* v8 ignore next -- the button is disabled while nothing is addable */
|
|
if (first === undefined) return
|
|
setSavedTarget(undefined)
|
|
setAdding(true)
|
|
setEditing(targetOf(first))
|
|
}}
|
|
>
|
|
{/* Same glyph as the composer's attach button. */}
|
|
<IconPlusOutline16 size={14} />
|
|
{t('add')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<Modal
|
|
open={deleteTarget !== undefined}
|
|
onClose={closeDelete}
|
|
title={deleteTarget === undefined ? '' : providerCopy(t('deleteTitle'), deleteTarget)}
|
|
closeLabel={t('close')}
|
|
description={deleteTarget === undefined
|
|
? ''
|
|
: providerCopy(
|
|
deleteTarget.credentialRef === undefined
|
|
? t('deleteDescription')
|
|
: t('deleteDescriptionWithCredential'),
|
|
deleteTarget,
|
|
)}
|
|
className={styles['deleteDialog'] as string}
|
|
footer={(
|
|
<>
|
|
<Button variant="outline" autoFocus disabled={deleting} onClick={closeDelete}>
|
|
{t('cancel')}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className={styles['deleteConfirm']}
|
|
disabled={deleting}
|
|
onClick={confirmDelete}
|
|
>
|
|
{deleteTarget === undefined
|
|
? ''
|
|
: providerCopy(deleting ? t('deleting') : t('deleteConfirm'), deleteTarget)}
|
|
</Button>
|
|
</>
|
|
)}
|
|
>
|
|
{deleteFailure === undefined ? null : <p className={styles['error']}>{deleteFailure}</p>}
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|