Merge remote-tracking branch 'origin/master' into worktree/default-model-persistence
# Conflicts: # apps/web/tests/models-settings.e2e.ts # packages/client/ui-models/README.i18n.yaml # packages/client/ui-models/README.md # packages/client/ui-models/README.zh.md # packages/client/ui-models/src/client/ModelsSection.module.css # packages/client/ui-models/src/client/ModelsSection.tsx
This commit is contained in:
78 files changed
+3662
-368
No files matched your search
@@ -38,6 +38,13 @@
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
.savedNotice {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.rows {
|
||||
list-style: none;
|
||||
/* Extra air between the title/intro block and the first provider card. */
|
||||
@@ -65,6 +72,13 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rowIdentity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rowName {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
@@ -73,9 +87,9 @@
|
||||
}
|
||||
|
||||
/* Reads as an annotation on the name, not as a second name: caption size and
|
||||
the secondary label tone, so it never competes with the row's own title.
|
||||
`rowActions` keeps the `margin-left: auto`, which is what holds the tag
|
||||
beside the name instead of letting it drift across the row. */
|
||||
the secondary label tone, so it never competes with the row's own title. It
|
||||
sits inside `rowIdentity` with the credential dot, which is what keeps it
|
||||
beside the name rather than drifting toward the actions. */
|
||||
.rowTag {
|
||||
flex: none;
|
||||
padding: 1px 6px;
|
||||
@@ -86,6 +100,23 @@
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.credentialDot {
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.credentialDotConfigured {
|
||||
background: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.credentialDotMissing {
|
||||
background: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Models settings section: the provider rows joined from the configurable
|
||||
* directory, settings namespaces, and credential states, with one editor
|
||||
* card at a time. 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
|
||||
* 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.
|
||||
*/
|
||||
@@ -15,9 +16,9 @@ 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 { CustomProviderCard } from './CustomProviderCard.tsx'
|
||||
import { messageOf, protocolChoices } from './store.ts'
|
||||
import { deriveKeyRef, messageOf, protocolChoices } from './store.ts'
|
||||
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
|
||||
import { ProviderEditor } from './ProviderEditor.tsx'
|
||||
import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
@@ -39,42 +40,73 @@ export interface ModelsSectionInjected {
|
||||
*/
|
||||
export type ModelsSectionProps = Partial<ModelsSectionInjected>
|
||||
|
||||
/** The editor target: an existing row or a dormant directory entry. */
|
||||
interface EditorTarget {
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** Values that vary around the shared provider-editor rendering. */
|
||||
interface ProviderEditorRenderProps extends Pick<
|
||||
ProviderEditorProps,
|
||||
'namespace' | 'api' | 't' | 'readOnly' | 'onClose'
|
||||
> {
|
||||
target: EditorTarget
|
||||
}
|
||||
|
||||
/** Render an editor for either the setup posture or an expanded provider row. */
|
||||
function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): ReactNode {
|
||||
return (
|
||||
<ProviderEditor
|
||||
provider={target.provider}
|
||||
displayName={target.displayName}
|
||||
settingsPath={target.settingsPath}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one user-added provider profile by unsetting its path in the stored
|
||||
* user section, then reload. The removal names the profile rather than
|
||||
* rebuilding the section: this page only ever holds the redacted descriptor,
|
||||
* so a rebuilt section would drop every literal secret stored elsewhere in
|
||||
* the namespace along with the profile being removed.
|
||||
* @param api - settings wire face.
|
||||
* 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.
|
||||
* @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'>,
|
||||
api: Pick<IApiClient, 'settings' | 'credentials'>,
|
||||
controller: ModelsSettingsStore,
|
||||
target: { settingsNs: string; settingsPath: readonly string[] },
|
||||
target: { settingsNs: string; settingsPath: readonly string[]; credentialRef?: string },
|
||||
): Promise<string | undefined> {
|
||||
let response
|
||||
try {
|
||||
response = await api.settings.mutate({
|
||||
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 say so instead of the row silently staying put.
|
||||
// to retry the idempotent operation instead of the row silently staying.
|
||||
return messageOf(error)
|
||||
}
|
||||
if (!response.result.ok) return response.result.error.message
|
||||
await controller.load()
|
||||
return undefined
|
||||
}
|
||||
@@ -93,14 +125,33 @@ export function needsSetup(row: ProviderRow): boolean {
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -119,28 +170,35 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
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 [declaring, setDeclaring] = useState(false)
|
||||
|
||||
const closeEditor = (changed: boolean): void => {
|
||||
const closeEditor = (changed: boolean, target: ProviderIdentity): void => {
|
||||
setEditing(undefined)
|
||||
setAdding(false)
|
||||
setDeclaring(false)
|
||||
if (changed) void controller.load()
|
||||
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) {
|
||||
controller.fail(failure)
|
||||
setDeleteFailure(failure)
|
||||
return
|
||||
}
|
||||
setDeleteTarget(undefined)
|
||||
@@ -176,6 +234,13 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
<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)
|
||||
@@ -187,35 +252,60 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
// 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={closeEditor}
|
||||
/>
|
||||
{renderProviderEditor({
|
||||
target,
|
||||
namespace,
|
||||
api,
|
||||
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['rowName']}>{row.entry.displayName}</span>
|
||||
{/* Only the adapter can tell a hand-declared route from a
|
||||
shipped one it also has a stored profile for, so the tag
|
||||
follows its answer and stays off when it gives none. */}
|
||||
{row.entry.declared === true
|
||||
? <span className={styles['rowTag']}>{t('customTag')}</span>
|
||||
: null}
|
||||
<span className={styles['rowIdentity']}>
|
||||
<span className={styles['rowName']}>{row.entry.displayName}</span>
|
||||
{/* Only the adapter can tell a hand-declared route from a
|
||||
shipped one it also has a stored profile for, so the tag
|
||||
follows its answer and stays off when it gives none. */}
|
||||
{row.entry.declared === true
|
||||
? <span className={styles['rowTag']}>{t('customTag')}</span>
|
||||
: null}
|
||||
{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)
|
||||
// One card at a time: leaving `declaring` set would show
|
||||
// the create card beside this editor, and closing either
|
||||
// one discards the other's draft.
|
||||
@@ -231,8 +321,13 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
className={styles['dangerButton']}
|
||||
aria-label={providerCopy(t('removeProvider'), target)}
|
||||
disabled={!state.writable}
|
||||
onClick={() => { setDeleteTarget(target) }}
|
||||
onClick={() => {
|
||||
setSavedTarget(undefined)
|
||||
setDeleteFailure(undefined)
|
||||
setDeleteTarget(target)
|
||||
}}
|
||||
>
|
||||
{t('remove')}
|
||||
</button>
|
||||
@@ -241,18 +336,14 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
</span>
|
||||
</div>
|
||||
{open
|
||||
? (
|
||||
<ProviderEditor
|
||||
provider={target.provider}
|
||||
displayName={target.displayName}
|
||||
namespace={namespace}
|
||||
settingsPath={target.settingsPath}
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
)
|
||||
? renderProviderEditor({
|
||||
target,
|
||||
namespace,
|
||||
api,
|
||||
t,
|
||||
readOnly: !state.writable,
|
||||
onClose: (changed) => { closeEditor(changed, target) },
|
||||
})
|
||||
: null}
|
||||
</li>
|
||||
)
|
||||
@@ -290,7 +381,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={closeEditor}
|
||||
onClose={(changed) => { closeEditor(changed, addTarget) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -305,7 +396,10 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={closeEditor}
|
||||
onClose={(changed) => {
|
||||
setDeclaring(false)
|
||||
if (changed) void controller.load()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -323,6 +417,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
const first = addable[0]
|
||||
/* v8 ignore next -- the button is disabled while nothing is addable */
|
||||
if (first === undefined) return
|
||||
setSavedTarget(undefined)
|
||||
setDeclaring(false)
|
||||
setAdding(true)
|
||||
setEditing(targetOf(first))
|
||||
@@ -336,7 +431,12 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
type="button"
|
||||
className={styles['addButton']}
|
||||
disabled={protocols.length === 0 || !state.writable}
|
||||
onClick={() => { setAdding(false); setEditing(undefined); setDeclaring(true) }}
|
||||
onClick={() => {
|
||||
setSavedTarget(undefined)
|
||||
setAdding(false)
|
||||
setEditing(undefined)
|
||||
setDeclaring(true)
|
||||
}}
|
||||
>
|
||||
<IconPlusOutline16 size={14} />
|
||||
{t('customAdd')}
|
||||
@@ -347,9 +447,16 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
<Modal
|
||||
open={deleteTarget !== undefined}
|
||||
onClose={closeDelete}
|
||||
title={t('deleteTitle')}
|
||||
title={deleteTarget === undefined ? '' : providerCopy(t('deleteTitle'), deleteTarget)}
|
||||
closeLabel={t('close')}
|
||||
description={t('deleteDescription')}
|
||||
description={deleteTarget === undefined
|
||||
? ''
|
||||
: providerCopy(
|
||||
deleteTarget.credentialRef === undefined
|
||||
? t('deleteDescription')
|
||||
: t('deleteDescriptionWithCredential'),
|
||||
deleteTarget,
|
||||
)}
|
||||
className={styles['deleteDialog'] as string}
|
||||
footer={(
|
||||
<>
|
||||
@@ -362,11 +469,15 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
disabled={deleting}
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
{deleting ? t('deleting') : t('deleteConfirm')}
|
||||
{deleteTarget === undefined
|
||||
? ''
|
||||
: providerCopy(deleting ? t('deleting') : t('deleteConfirm'), deleteTarget)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{deleteFailure === undefined ? null : <p className={styles['error']}>{deleteFailure}</p>}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
* field is a single write-only **API key** input (the page never asks for an
|
||||
* environment-variable name — a typed key stores through `credentials.set`
|
||||
* under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile
|
||||
* has none, and the pi-ai profile records that derivation as `apiKeyEnv`);
|
||||
* has none. The pi-ai profile records that derivation as `apiKeyEnv` only when
|
||||
* a key is entered; a blank key materializes a reference-free profile for
|
||||
* provider-native authentication);
|
||||
* the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
|
||||
* both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, and
|
||||
* DeepSeek's id/name/context-window model catalog). Everything else stays
|
||||
@@ -123,10 +125,13 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
const [keyState, setKeyState] = useState<CredentialView | undefined>(undefined)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failure, setFailure] = useState<string | undefined>(undefined)
|
||||
// The revision this card opened at. A write carrying it is refused if
|
||||
// anything else — another tab, an external edit of settings.yaml — moved the
|
||||
// namespace meanwhile, instead of silently overwriting that change.
|
||||
const [openedAt] = useState(() => namespace.revision)
|
||||
// A settings success advances both retry baselines immediately. Keeping the
|
||||
// derived fields in the draft prevents a pushed namespace refresh from
|
||||
// turning them into deletions when the following credential write is retried.
|
||||
const [committedOriginal, setCommittedOriginal] = useState<unknown>(
|
||||
() => getPath(namespace.user, settingsPath),
|
||||
)
|
||||
const [expectedRevision, setExpectedRevision] = useState(() => namespace.revision)
|
||||
const root = useMemo(() => rehydrateSchema(namespace.schema), [namespace.schema])
|
||||
const node = useMemo(() => nodeAtPath(root, settingsPath), [root, settingsPath])
|
||||
const fallback = getPath(namespace.value, settingsPath)
|
||||
@@ -184,11 +189,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
*/
|
||||
const applyOnce = async (): Promise<string | undefined> => {
|
||||
const ns = namespace.ns
|
||||
const original = getPath(namespace.user, settingsPath)
|
||||
// The pi-ai profile must name the reference the key stores under, so a
|
||||
// dormant add (or a legacy profile without one) records the derivation.
|
||||
const normalizedKey = keyDraft.trim()
|
||||
// A pi-ai profile names the conventional reference only when this page is
|
||||
// about to store a key. Otherwise the provider keeps its native auth path.
|
||||
const next = layout === 'pi-ai' && stringAt(draft, 'apiKeyEnv') === undefined
|
||||
&& stringAt(fallback, 'apiKeyEnv') === undefined
|
||||
&& stringAt(fallback, 'apiKeyEnv') === undefined && normalizedKey.length > 0
|
||||
? setPath(draft, ['apiKeyEnv'], keyRef)
|
||||
: draft
|
||||
{
|
||||
@@ -207,17 +212,26 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
const sectionError = validateDraft(node, next)
|
||||
if (sectionError !== undefined) return sectionError
|
||||
}
|
||||
const ops = pathOps(settingsPath, original, next)
|
||||
const materializesNativeProfile = layout === 'pi-ai'
|
||||
&& fallback === undefined
|
||||
&& committedOriginal === undefined
|
||||
&& Object.keys(next).length === 0
|
||||
const ops: SettingsPathOpView[] = materializesNativeProfile
|
||||
? [{ op: 'set', path: [...settingsPath], value: {} }]
|
||||
: pathOps(settingsPath, committedOriginal, next)
|
||||
if (ops.length > 0) {
|
||||
const response = await api.settings.mutate({ ns, ops, expectedRevision: openedAt })
|
||||
const response = await api.settings.mutate({ ns, ops, expectedRevision })
|
||||
if (!response.result.ok) {
|
||||
return response.result.error.code === 'settings-conflict'
|
||||
? t('conflict')
|
||||
: response.result.error.message
|
||||
}
|
||||
setCommittedOriginal(getPath(response.result.value.user, settingsPath))
|
||||
setExpectedRevision(response.result.value.revision)
|
||||
setDraft(next)
|
||||
}
|
||||
if (keyDraft.length > 0) {
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: keyDraft })
|
||||
if (normalizedKey.length > 0) {
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: normalizedKey })
|
||||
if (!stored.result.ok) return stored.result.error.message
|
||||
}
|
||||
setKeyDraft('')
|
||||
@@ -276,6 +290,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
|
||||
const defaultContextWindow = getPath(fallback, ['defaultContextWindow'])
|
||||
const defaultMaxTokens = getPath(fallback, ['maxTokens'])
|
||||
const keyPlaceholder = keyLocked
|
||||
? t('keyEnvLocked')
|
||||
: keyState?.configured === 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. */
|
||||
const catalogProps = {
|
||||
models,
|
||||
@@ -296,9 +315,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={keyDraft}
|
||||
placeholder={keyLocked
|
||||
? t('keyEnvLocked')
|
||||
: keyState?.configured === true ? t('keyStored') : t('keyPlaceholder')}
|
||||
placeholder={keyPlaceholder}
|
||||
aria-label={t('keyInput')}
|
||||
disabled={disabled || keyLocked}
|
||||
onChange={(event) => { setKeyDraft(event.target.value) }}
|
||||
|
||||
@@ -6,23 +6,30 @@ export const en = {
|
||||
title: 'Models',
|
||||
intro: 'Enter your API keys to use models from the following providers.',
|
||||
edit: 'Edit',
|
||||
editProvider: 'Edit {provider}',
|
||||
remove: 'Delete',
|
||||
deleteTitle: 'Delete model provider?',
|
||||
deleteDescription: 'Deleting this model provider removes its configuration. You will not be able to use its models until you add the provider again.',
|
||||
deleteConfirm: 'Delete provider',
|
||||
deleting: 'Deleting provider…',
|
||||
removeProvider: 'Delete {provider}',
|
||||
deleteTitle: 'Delete {provider}?',
|
||||
deleteDescription: 'Deleting {provider} removes its configuration. Any credential it uses is managed elsewhere and will be kept.',
|
||||
deleteDescriptionWithCredential: 'Deleting {provider} removes its configuration and stored API key.',
|
||||
deleteConfirm: 'Delete {provider}',
|
||||
deleting: 'Deleting {provider}…',
|
||||
add: 'Add provider',
|
||||
provider: 'Provider',
|
||||
close: 'Close',
|
||||
cancel: 'Cancel',
|
||||
apply: 'Apply',
|
||||
applying: 'Applying…',
|
||||
savedProvider: 'Saved {provider}.',
|
||||
credentialConfigured: 'API key configured',
|
||||
credentialMissing: 'API key missing',
|
||||
readOnly: 'The settings document is read-only in this deployment.',
|
||||
loadFailed: 'Loading the provider directory failed',
|
||||
conflict: 'Someone else changed these settings while this card was open. Close it and reopen to edit the current values.',
|
||||
retry: 'Retry',
|
||||
keyInput: 'API key',
|
||||
keyPlaceholder: 'Enter your API key',
|
||||
keyPlaceholderNative: 'Enter an API key, or leave blank to use environment authentication',
|
||||
keyStored: 'Configured — enter a new value to replace',
|
||||
keyEnvLocked: 'Provided by the launch environment (read-only)',
|
||||
customized: 'Customized settings',
|
||||
@@ -91,23 +98,30 @@ export const zh: typeof en = {
|
||||
title: '模型',
|
||||
intro: '填入各提供方的 API 密钥即可使用其模型。',
|
||||
edit: '编辑',
|
||||
editProvider: '编辑 {provider}',
|
||||
remove: '删除',
|
||||
deleteTitle: '删除模型提供方?',
|
||||
deleteDescription: '删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。',
|
||||
deleteConfirm: '删除提供方',
|
||||
deleting: '正在删除提供方…',
|
||||
removeProvider: '删除 {provider}',
|
||||
deleteTitle: '删除 {provider}?',
|
||||
deleteDescription: '删除 {provider} 会移除其配置;其使用的凭证(如有)由其他位置管理,将会保留。',
|
||||
deleteDescriptionWithCredential: '删除 {provider} 会移除其配置和存储的 API 密钥。',
|
||||
deleteConfirm: '删除 {provider}',
|
||||
deleting: '正在删除 {provider}…',
|
||||
add: '添加提供方',
|
||||
provider: '提供方',
|
||||
close: '关闭',
|
||||
cancel: '取消',
|
||||
apply: '保存',
|
||||
applying: '保存中…',
|
||||
savedProvider: '已保存 {provider}。',
|
||||
credentialConfigured: 'API 密钥已配置',
|
||||
credentialMissing: 'API 密钥缺失',
|
||||
readOnly: '当前部署的设置文档为只读。',
|
||||
loadFailed: '加载提供方目录失败',
|
||||
conflict: '这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。',
|
||||
retry: '重试',
|
||||
keyInput: 'API 密钥',
|
||||
keyPlaceholder: '输入 API 密钥',
|
||||
keyPlaceholderNative: '输入 API 密钥,或留空使用环境认证',
|
||||
keyStored: '已配置——输入新值可替换',
|
||||
keyEnvLocked: '由启动环境提供(只读)',
|
||||
customized: '自定义设置',
|
||||
|
||||
@@ -125,18 +125,6 @@ export class ModelsSettingsStore {
|
||||
*/
|
||||
constructor(private readonly api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>) {}
|
||||
|
||||
/**
|
||||
* Surface a failure from an operation the page ran outside {@link load} —
|
||||
* a row removal — on the same banner a load failure uses.
|
||||
* @param message - the failure text to show.
|
||||
*/
|
||||
fail(message: string): void {
|
||||
this.store.update((s) => {
|
||||
s.status = 'error'
|
||||
s.error = message
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the whole page snapshot: directory and namespaces in parallel,
|
||||
* then one batched credential describe over every referenced ref. A
|
||||
|
||||
Reference in New Issue
Block a user