Merge remote-tracking branch 'origin/worktree-llm-web-config' into worktree/onboarding-deepseek-config
# Conflicts: # 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/store.ts # packages/client/ui-models/tests/components.spec.tsx
This commit is contained in:
@@ -10,9 +10,9 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { deletePath } from '@deepseek-ai/dsh-client-schema-form'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { messageOf } from './store.ts'
|
||||
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
|
||||
import { ProviderEditor } from './ProviderEditor.tsx'
|
||||
import type { en } from './locales.ts'
|
||||
@@ -45,24 +45,35 @@ interface EditorTarget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one user-added provider profile from its namespace's user section
|
||||
* (wholesale replace — merge cannot express a removal) and reload on success.
|
||||
* 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.
|
||||
* @param controller - the page store to refresh.
|
||||
* @param target - the provider's settings address.
|
||||
* @param namespace - the owning namespace view.
|
||||
* @returns settles when the write and any reload finished.
|
||||
* @returns the failure message, or undefined once the write and reload landed.
|
||||
*/
|
||||
export async function removeProviderProfile(
|
||||
api: Pick<IApiClient, 'settings'>,
|
||||
controller: ModelsSettingsStore,
|
||||
target: { settingsNs: string; settingsPath: readonly string[] },
|
||||
namespace: SettingsNamespaceView,
|
||||
): Promise<void> {
|
||||
const user = structuredClone((namespace.user ?? {}) as Record<string, unknown>)
|
||||
const next = deletePath(user, [...target.settingsPath])
|
||||
const response = await api.settings.replace({ ns: target.settingsNs, section: next })
|
||||
if (response.result.ok) await controller.load()
|
||||
): Promise<string | undefined> {
|
||||
let response
|
||||
try {
|
||||
response = await api.settings.mutate({
|
||||
ns: target.settingsNs,
|
||||
ops: [{ op: 'unset', path: [...target.settingsPath] }],
|
||||
})
|
||||
} catch (error) {
|
||||
// The transport rejected rather than answering; the caller must be able
|
||||
// to say so instead of the row silently staying put.
|
||||
return messageOf(error)
|
||||
}
|
||||
if (!response.result.ok) return response.result.error.message
|
||||
await controller.load()
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,7 +193,11 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
type="button"
|
||||
className={styles['dangerButton']}
|
||||
disabled={!state.writable}
|
||||
onClick={() => { void removeProviderProfile(api, controller, target, namespace) }}
|
||||
onClick={() => {
|
||||
void removeProviderProfile(api, controller, target).then((failure) => {
|
||||
if (failure !== undefined) controller.fail(failure)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('remove')}
|
||||
</button>
|
||||
|
||||
@@ -6,19 +6,19 @@
|
||||
* has none, and the pi-ai profile records that derivation as `apiKeyEnv`);
|
||||
* the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
|
||||
* both families, plus `reasoningEffort` for deepseek / `reasoning` for
|
||||
* pi-ai). Everything else stays owned by `settings.yaml`. Profile edits land as a
|
||||
* minimal `settings.update` merge patch; clearing a field back to inherited
|
||||
* removes its key, so that apply replaces the user section (safe: the section
|
||||
* stores references, never key values).
|
||||
* pi-ai). Everything else stays owned by `settings.yaml`. Profile edits land as
|
||||
* minimal `settings.mutate` path ops against the stored section — the card
|
||||
* reads the redacted descriptor, so it names only the fields it can see and a
|
||||
* stored literal secret is never collaterally removed.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { CredentialView, IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { CredentialView, IApiClient, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
deletePath, getPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
|
||||
} from '@deepseek-ai/dsh-client-schema-form'
|
||||
import { deriveKeyRef } from './store.ts'
|
||||
import { deriveKeyRef, messageOf } from './store.ts'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
@@ -70,21 +70,33 @@ function draftAt(namespace: SettingsNamespaceView, path: readonly string[]): Rec
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any key present in `before` is absent from `after` (a reset
|
||||
* happened somewhere in the draft, so the apply must replace, not merge).
|
||||
* @param before - the user-layer subtree the draft started from.
|
||||
* @param after - the edited draft.
|
||||
* @returns whether a removal exists at any depth.
|
||||
* The minimal path ops carrying `after` over `before`, both as the card sees
|
||||
* them (that is, redacted). Only keys the card observed are named: a stored
|
||||
* `role('secret')` field appears in neither side, so it produces no op and
|
||||
* survives the write — the whole reason edits are path-addressed rather than
|
||||
* a rebuilt section.
|
||||
* @param base - path of the edited subtree inside the user section.
|
||||
* @param before - the subtree as loaded, or undefined when it is new.
|
||||
* @param after - the subtree as edited.
|
||||
* @returns ordered set/unset ops; empty when nothing changed.
|
||||
*/
|
||||
export function removedAny(before: unknown, after: unknown): boolean {
|
||||
if (typeof before !== 'object' || before === null) return false
|
||||
/* v8 ignore next -- the editor edits containers in place; a container cannot become a primitive */
|
||||
if (typeof after !== 'object' || after === null) return true
|
||||
for (const [key, value] of Object.entries(before)) {
|
||||
if (!(key in (after as Record<string, unknown>))) return true
|
||||
if (removedAny(value, (after as Record<string, unknown>)[key])) return true
|
||||
export function pathOps(
|
||||
base: readonly string[],
|
||||
before: unknown,
|
||||
after: Record<string, unknown>,
|
||||
): SettingsPathOpView[] {
|
||||
const previous = typeof before === 'object' && before !== null && !Array.isArray(before)
|
||||
? before as Record<string, unknown>
|
||||
: {}
|
||||
const ops: SettingsPathOpView[] = []
|
||||
for (const [key, value] of Object.entries(after)) {
|
||||
if (JSON.stringify(previous[key]) === JSON.stringify(value)) continue
|
||||
ops.push({ op: 'set', path: [...base, key], value })
|
||||
}
|
||||
return false
|
||||
for (const key of Object.keys(previous)) {
|
||||
if (!(key in after)) ops.push({ op: 'unset', path: [...base, key] })
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
/** The editor layout the owning namespace selects. */
|
||||
@@ -115,6 +127,10 @@ 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)
|
||||
const root = useMemo(() => rehydrateSchema(namespace.schema), [namespace.schema])
|
||||
const node = useMemo(() => nodeAtPath(root, settingsPath), [root, settingsPath])
|
||||
const fallback = getPath(namespace.value, settingsPath)
|
||||
@@ -125,10 +141,17 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
setKeyState(undefined)
|
||||
void api.credentials.describe({ refs: [keyRef] }).then((response) => {
|
||||
if (stale || !response.result.ok) return
|
||||
setKeyState(response.result.value.credentials[keyRef])
|
||||
})
|
||||
// The key state is a placeholder hint, not a precondition for editing:
|
||||
// neither a business rejection nor a transport failure may reach the
|
||||
// browser as an unhandled rejection, so the card simply renders without
|
||||
// the "already configured" hint.
|
||||
void api.credentials.describe({ refs: [keyRef] }).then(
|
||||
(response) => {
|
||||
if (stale || !response.result.ok) return
|
||||
setKeyState(response.result.value.credentials[keyRef])
|
||||
},
|
||||
() => undefined,
|
||||
)
|
||||
return () => { stale = true }
|
||||
}, [api.credentials, keyRef])
|
||||
|
||||
@@ -140,9 +163,14 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
setDraft(current => next === undefined ? deletePath(current, [key]) : setPath(current, [key], next))
|
||||
}
|
||||
|
||||
const apply = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
setFailure(undefined)
|
||||
/**
|
||||
* The write for this card, or a failure message. Every edit travels as
|
||||
* path ops against the STORED section: the draft comes from the redacted
|
||||
* descriptor, so a wholesale replace rebuilt from it would delete the
|
||||
* literal secrets the wire never returned. Ops name only the fields this
|
||||
* card can see, so a stored secret is untouched by construction.
|
||||
*/
|
||||
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
|
||||
@@ -151,45 +179,46 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
&& stringAt(fallback, 'apiKeyEnv') === undefined
|
||||
? setPath(draft, ['apiKeyEnv'], keyRef)
|
||||
: draft
|
||||
const settingsChanged = JSON.stringify(next) !== JSON.stringify(original ?? {})
|
||||
if (settingsChanged) {
|
||||
const needsReplace = removedAny(original, next)
|
||||
// Merge patches stay minimal (just this profile); a replace must carry
|
||||
// the complete next user section because it lands wholesale.
|
||||
const patch = settingsPath.length === 0 ? next : setPath({}, [...settingsPath], next)
|
||||
/* v8 ignore next 3 -- a subtree apply implies the join served this namespace's user layer */
|
||||
const nextSection = settingsPath.length === 0
|
||||
? next
|
||||
: setPath(structuredClone((namespace.user ?? {}) as Record<string, unknown>), [...settingsPath], next)
|
||||
/* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
|
||||
if (node !== undefined) {
|
||||
const sectionError = settingsPath.length === 0 ? validateDraft(node, next) : undefined
|
||||
if (sectionError !== undefined) {
|
||||
setBusy(false)
|
||||
setFailure(sectionError)
|
||||
return
|
||||
}
|
||||
}
|
||||
const response = needsReplace
|
||||
? await api.settings.replace({ ns, section: nextSection })
|
||||
: await api.settings.update({ ns, patch })
|
||||
/* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
|
||||
if (node !== undefined && settingsPath.length === 0) {
|
||||
const sectionError = validateDraft(node, next)
|
||||
if (sectionError !== undefined) return sectionError
|
||||
}
|
||||
const ops = pathOps(settingsPath, original, next)
|
||||
if (ops.length > 0) {
|
||||
const response = await api.settings.mutate({ ns, ops, expectedRevision: openedAt })
|
||||
if (!response.result.ok) {
|
||||
setBusy(false)
|
||||
setFailure(response.result.error.message)
|
||||
return
|
||||
return response.result.error.code === 'settings-conflict'
|
||||
? t('conflict')
|
||||
: response.result.error.message
|
||||
}
|
||||
}
|
||||
if (keyDraft.length > 0) {
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: keyDraft })
|
||||
if (!stored.result.ok) {
|
||||
setBusy(false)
|
||||
setFailure(stored.result.error.message)
|
||||
if (!stored.result.ok) return stored.result.error.message
|
||||
}
|
||||
setKeyDraft('')
|
||||
return undefined
|
||||
}
|
||||
|
||||
const apply = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
setFailure(undefined)
|
||||
try {
|
||||
const failure = await applyOnce()
|
||||
if (failure !== undefined) {
|
||||
setFailure(failure)
|
||||
return
|
||||
}
|
||||
setKeyDraft('')
|
||||
props.onClose(true)
|
||||
} catch (error) {
|
||||
// A transport failure (disconnect, a request the host refuses) rejects
|
||||
// rather than answering; without this the card would stay busy forever
|
||||
// with no error shown.
|
||||
setFailure(messageOf(error))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
setBusy(false)
|
||||
props.onClose(true)
|
||||
}
|
||||
|
||||
if (node === undefined) {
|
||||
|
||||
@@ -16,6 +16,7 @@ export const en = {
|
||||
applying: 'Applying…',
|
||||
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',
|
||||
@@ -54,6 +55,7 @@ export const zh: typeof en = {
|
||||
applying: '保存中…',
|
||||
readOnly: '当前部署的设置文档为只读。',
|
||||
loadFailed: '加载提供方目录失败',
|
||||
conflict: '这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。',
|
||||
retry: '重试',
|
||||
keyInput: 'API 密钥',
|
||||
keyPlaceholder: '输入 API 密钥',
|
||||
|
||||
@@ -44,6 +44,17 @@ export interface ModelsSettingsState {
|
||||
namespaces: ReadonlyMap<string, SettingsNamespaceView>
|
||||
}
|
||||
|
||||
/**
|
||||
* Human text for a rejected wire call. A transport failure rejects with an
|
||||
* Error; a host or a runtime can reject with anything, and the page still has
|
||||
* to say something.
|
||||
* @param error - the rejection value.
|
||||
* @returns the message to show.
|
||||
*/
|
||||
export function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the conventional credential reference for a provider route: the v1
|
||||
* page never asks for an environment-variable name, so a typed key stores
|
||||
@@ -77,11 +88,6 @@ function literalApiKeyConfigured(
|
||||
&& secret.path.every((key, index) => key === secretPath[index]))
|
||||
}
|
||||
|
||||
/** Safe display text for a rejected transport or business response. */
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/** The models settings page controller (one per settings surface). */
|
||||
export class ModelsSettingsStore {
|
||||
/** The snapshot the section renders from (uSES-safe store). */
|
||||
@@ -97,6 +103,18 @@ 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
|
||||
@@ -151,12 +169,13 @@ export class ModelsSettingsStore {
|
||||
if (refs.length > 0) {
|
||||
try {
|
||||
const response = await this.api.credentials.describe({ refs })
|
||||
// Credential state is an enrichment for the Models page, while the
|
||||
// onboarding readiness projection below reports its failure.
|
||||
// Credential state is an enrichment for the Models page: neither a
|
||||
// business rejection nor a transport failure fails the load. The
|
||||
// onboarding projection below retains the failure distinction.
|
||||
if (response.result.ok) credentials = response.result.value.credentials
|
||||
else credentialError = response.result.error.message
|
||||
} catch (error) {
|
||||
credentialError = errorText(error)
|
||||
credentialError = messageOf(error)
|
||||
}
|
||||
}
|
||||
if (generation !== this.generation) return
|
||||
|
||||
Reference in New Issue
Block a user