/** * The dsh-imagegen settings card: channel management (list rows with status * dots, an editor dialog with the model-catalog alias → upstream mapping, and * built-in provider presets), plus the prompt-enhancement model and the plugin * switches. Registers as the Image settings page in the Settings dialog's * left navigation, independent of the dsh-web-ui family group, and binds to * the plugin's own bridge settings scope. * * The interaction mirrors the host's model-provider page: one row per channel * (status dot + edit/delete), two add buttons (built-in provider / custom), * and an editor holding API key, display name, API URL, and the model catalog * with detection. */ import { useEffect, useRef, useState, type ReactNode } from 'react' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { CardForm, booleanField, secretField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts' import { ChannelsForm, type ChannelDraft, type ChannelsFormActions, type ChannelsFormState } from './channels-form.ts' import type { ImageGenScope } from './settings-scope.ts' import { describeModel } from '../model-catalog.ts' import { IMAGE_MODEL_API, PRESETS_API, PROMPT_ENHANCE_API, USAGE_API, CANVAS_SKILL_API, SUBSCRIPTION_API, SUBSCRIPTION_PROVIDERS, SUBSCRIPTION_PROVIDER_DISPLAY_NAMES, DEFAULT_SUBSCRIPTION_MODELS, EXPERIMENTAL_SUBSCRIPTION_PROVIDERS, isChatCompletionsUrl, isSubscriptionProvider, resolveChannelProtocol, type ModelMapping, type PresetProviderView, type SubscriptionProvider } from '../protocol.ts' import type { ImageGenKey } from './locales.ts' import { tt, type TranslateValues } from './helpers.ts' import { useImageGenLanguageTick } from './use-language.ts' import { OPEN_IMAGE_GEN_CONFIG_EVENT, takeImageGenConfigSection } from './config-entry.tsx' import { PptSetup } from './PptSetup.tsx' import { Select } from './Select.tsx' import css from './settings-card.module.css' /** The global (non-channel) fields this card's staged form edits. */ export interface ImageGenSettings { enabled?: boolean announceToAgent?: boolean allowAgentImageGeneration?: boolean promptApiUrl?: string promptApiKey?: string promptModel?: string localStoragePath?: string storageEnabled?: boolean storageEndpoint?: string storageRegion?: string storagePrefix?: string storageAccessKey?: string storageSecretKey?: string storageSyncGallery?: boolean storageSyncHistory?: boolean skillsEnabled?: boolean allowHeavySkills?: boolean skillAllowlist?: string skillOutputDir?: string skillHeavyTimeoutMinutes?: number skillAgentPreset?: string } /** What the card renders. */ export interface ImageGenSettingsCardState extends CardShell { /** Channel list staging (channels + per-channel key edits + default). */ channels: ChannelsFormState /** Master switch. */ enabled: CardFieldState /** System-prompt announcement flag. */ announceToAgent: CardFieldState allowAgentImageGeneration: CardFieldState promptApiUrl: CardFieldState promptApiKey: CardFieldState promptModel: CardFieldState localStoragePath: CardFieldState storageEnabled: CardFieldState storageEndpoint: CardFieldState storageRegion: CardFieldState storagePrefix: CardFieldState storageAccessKey: CardFieldState storageSecretKey: CardFieldState storageSyncGallery: CardFieldState storageSyncHistory: CardFieldState skillsEnabled: CardFieldState allowHeavySkills: CardFieldState skillAllowlist: CardFieldState skillOutputDir: CardFieldState skillHeavyTimeoutMinutes: CardFieldState skillAgentPreset: CardFieldState } /** Result of probing the configured object storage from the card. */ export type SubscriptionStatus = { state: 'logged-in'; email?: string; error?: string } | { state: 'logged-out'; error?: string } | { state: 'unknown'; error?: string } export type SubscriptionStatusMap = Record /** Result of probing the configured object storage from the card. */ export interface StorageTestOutcome { ok: boolean ms?: number message?: string } /** The registration-side face the card's slot entry injects. */ export interface ImageGenSettingsCardFace extends CardActions { /** Channel staging actions (committed together with the card's save). */ channels: ChannelsFormActions /** Save staged edits, then upload a probe object to the configured store. */ storageTest: () => Promise hooks: { /** Card snapshot bound by the renderer as useImageGenSettingsCard. */ imageGenSettingsCard: SnapshotStore } } /** Bridges the imagegen scope onto the card's staged forms. */ export class ImageGenSettingsCardController { private readonly form: CardForm private readonly channelsForm: ChannelsForm /** @param scope - the bound bridge scope for the dsh-imagegen namespace. */ constructor(private readonly scope: ImageGenScope) { this.form = new CardForm(scope, [ booleanField('enabled'), booleanField('announceToAgent'), booleanField('allowAgentImageGeneration'), textField('promptApiUrl'), secretField('promptApiKey'), textField('promptModel'), textField('localStoragePath'), booleanField('storageEnabled'), textField('storageEndpoint'), textField('storageRegion'), textField('storagePrefix'), textField('storageAccessKey'), secretField('storageSecretKey'), booleanField('storageSyncGallery'), booleanField('storageSyncHistory'), booleanField('skillsEnabled'), booleanField('allowHeavySkills'), textField('skillAllowlist'), textField('skillOutputDir'), textField('skillHeavyTimeoutMinutes'), textField('skillAgentPreset'), ], { secretSettled: (field) => this.scope.getSecretSetSnapshot(field), }) this.channelsForm = new ChannelsForm(scope) } private projection(): ImageGenSettingsCardState { const shell = this.form.shell() return { ...shell, dirty: shell.dirty || this.channelsForm.snapshot().dirty, channels: this.channelsForm.snapshot(), enabled: this.form.field('enabled'), announceToAgent: this.form.field('announceToAgent'), allowAgentImageGeneration: this.form.field('allowAgentImageGeneration'), promptApiUrl: this.form.field('promptApiUrl'), promptApiKey: this.form.field('promptApiKey'), promptModel: this.form.field('promptModel'), localStoragePath: this.form.field('localStoragePath'), storageEnabled: this.form.field('storageEnabled'), storageEndpoint: this.form.field('storageEndpoint'), storageRegion: this.form.field('storageRegion'), storagePrefix: this.form.field('storagePrefix'), storageAccessKey: this.form.field('storageAccessKey'), storageSecretKey: this.form.field('storageSecretKey'), storageSyncGallery: this.form.field('storageSyncGallery'), storageSyncHistory: this.form.field('storageSyncHistory'), skillsEnabled: this.form.field('skillsEnabled'), allowHeavySkills: this.form.field('allowHeavySkills'), skillAllowlist: this.form.field('skillAllowlist'), skillOutputDir: this.form.field('skillOutputDir'), skillHeavyTimeoutMinutes: this.form.field('skillHeavyTimeoutMinutes'), skillAgentPreset: this.form.field('skillAgentPreset'), } } /** * Build the face the card's slot registration injects. * @returns the card's snapshot and the form/channel actions. */ inject(): ImageGenSettingsCardFace { const cardStore = this.form.bind(() => this.projection()) this.channelsForm.subscribe(() => { cardStore.set(this.projection()) }) return { hooks: { imageGenSettingsCard: cardStore, }, channels: this.channelsForm.actions(), // The probe needs the values the user is looking at, so staged edits are // committed first; the host route then resolves the saved config itself. storageTest: async (): Promise => { await this.form.save() try { const response = await fetch('/api/dsh-imagegen/storage/test', { method: 'POST' }) const body = await response.json() as { ok?: unknown; ms?: unknown; message?: unknown } if (body.ok === true) return { ok: true, ms: typeof body.ms === 'number' ? body.ms : undefined } return { ok: false, message: typeof body.message === 'string' ? body.message : `HTTP ${response.status}` } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } }, ...this.form.actions(), } } } /** Props the renderer binds for this Settings navigation section. */ export type ImageGenSettingsSectionProps = PropsRuntime<'settings.section'> & PropsLocale<'dsh-imagegen'> & InjectFace /** Host-computed usage counters (generation-count badges). */ interface UsageCounters { byChannel: Record> totals: Record } type SettingsSectionId = 'channels' | 'prompt' | 'storage' | 'skills' | 'ppt' | 'general' | null /** One mutually-exclusive settings group. */ function SettingsSection(props: { id: Exclude title: string hint: string summary: string tone?: 'neutral' | 'ready' | 'attention' expanded: boolean onToggle: () => void children: ReactNode }) { const contentId = `dsh-imagegen-settings-${props.id}` return (
{props.expanded ?
{props.children}
: null}
) } /** * Render the card. * @param props - locale copy, the card snapshot, and the form actions. * @returns the card, or nothing while the namespace is still loading. */ export function ImageGenSettingsSection(props: ImageGenSettingsSectionProps) { // The card renders through the plugin's own dictionary so the uiLanguage // override applies here too — the host-locale props.t would only follow the // DSH interface language. const t = tt useImageGenLanguageTick() const state = props.useImageGenSettingsCard(snapshot => snapshot) const [open, setOpen] = useState(true) const [activeSection, setActiveSection] = useState(() => takeImageGenConfigSection() ?? 'channels') useEffect(() => { const open = (): void => { const section = takeImageGenConfigSection(); if (section) setActiveSection(section) } window.addEventListener(OPEN_IMAGE_GEN_CONFIG_EVENT, open) return () => window.removeEventListener(OPEN_IMAGE_GEN_CONFIG_EVENT, open) }, []) // Global-section local states (prompt enhancement etc.). const [promptModels, setPromptModels] = useState([]) const [loadingPromptModels, setLoadingPromptModels] = useState(false) const [promptModelsError, setPromptModelsError] = useState(null) const [manualPromptModelOpen, setManualPromptModelOpen] = useState(false) const [manualPromptModel, setManualPromptModel] = useState('') const [promptApiOpen, setPromptApiOpen] = useState(false) const [storageTesting, setStorageTesting] = useState(false) const [storageTestResult, setStorageTestResult] = useState(null) const [skillProbing, setSkillProbing] = useState(false) const [skillProbeResult, setSkillProbeResult] = useState(null) const [subscriptionStatus, setSubscriptionStatus] = useState(() => ({ 'chatgpt-sub': { state: 'unknown' }, 'grok-sub': { state: 'unknown' }, 'google-sub': { state: 'unknown' }, 'openrouter-sub': { state: 'unknown' }, })) const [subscriptionBusy, setSubscriptionBusy] = useState>>({}) const [subscriptionPending, setSubscriptionPending] = useState([]) const [subscriptionMessage, setSubscriptionMessage] = useState>>({}) /** Browser OAuth/device tabs opened for each pending subscription login. */ const subscriptionPopups = useRef>>({}) // Channel list local states. const [editingId, setEditingId] = useState(null) const [presetPickerOpen, setPresetPickerOpen] = useState(false) const [presets, setPresets] = useState([]) const [presetError, setPresetError] = useState(null) const [usage, setUsage] = useState(null) const [confirmDeleteId, setConfirmDeleteId] = useState(null) useEffect(() => () => { for (const popup of Object.values(subscriptionPopups.current)) { try { popup?.close() } catch { /* ignore */ } } subscriptionPopups.current = {} }, []) const refreshSubscriptions = async (): Promise => { const response = await fetch(SUBSCRIPTION_API.status, { method: 'POST', cache: 'no-store' }) const body = await response.json() as { ok?: boolean; statuses?: Record; message?: string } if (!response.ok || body.ok !== true || body.statuses === undefined) throw new Error(body.message ?? `HTTP ${response.status}`) const next: SubscriptionStatusMap = { 'chatgpt-sub': { state: 'unknown' }, 'grok-sub': { state: 'unknown' }, 'google-sub': { state: 'unknown' }, 'openrouter-sub': { state: 'unknown' }, } for (const provider of SUBSCRIPTION_PROVIDERS) { const row = body.statuses[provider] if (row?.state === 'logged-in') next[provider] = { state: 'logged-in', ...(typeof row.email === 'string' ? { email: row.email } : {}) } else if (row?.state === 'logged-out') next[provider] = { state: 'logged-out', ...(typeof row.error === 'string' ? { error: row.error } : {}) } } const errors = Object.fromEntries(Object.entries(next).flatMap(([provider, status]) => status.error === undefined ? [] : [[provider, status.error]])) if (Object.keys(errors).length > 0) setSubscriptionMessage(current => ({ ...current, ...errors })) setSubscriptionStatus(next) return next } useEffect(() => { if (!state.exposed || !open) return let active = true const load = (): void => { void refreshSubscriptions().catch(() => { /* badge is best-effort */ }) } load() const onFocus = (): void => { if (active) load() } window.addEventListener('focus', onFocus) return () => { active = false; window.removeEventListener('focus', onFocus) } }, [state.exposed, open]) useEffect(() => { const pending = subscriptionPending.filter(provider => subscriptionStatus[provider].state !== 'logged-in') if (!open || pending.length === 0) return const timer = window.setInterval(() => { void refreshSubscriptions().then(next => { const landed = pending.filter(provider => next[provider].state === 'logged-in') if (landed.length > 0) { for (const provider of landed) { const popup = subscriptionPopups.current[provider] if (popup !== undefined) { try { popup.close() } catch { /* cross-origin close is still best-effort */ } delete subscriptionPopups.current[provider] } } setSubscriptionPending(current => current.filter(provider => next[provider].state !== 'logged-in')) setSubscriptionMessage(current => ({ ...current, ...Object.fromEntries(landed.map(provider => [provider, t('settings.subscriptionLoginOk')])) })) } }).catch(() => { /* keep polling through transient failures */ }) }, 2000) const timeout = window.setTimeout(() => { setSubscriptionPending([]) }, 10 * 60_000) return () => { window.clearInterval(timer); window.clearTimeout(timeout) } }, [open, subscriptionPending.join(','), subscriptionStatus]) const subscriptionLogin = async (provider: SubscriptionProvider): Promise => { const previous = subscriptionPopups.current[provider] if (previous !== undefined) { try { previous.close() } catch { /* ignore */ } } const popup = window.open('about:blank', '_blank') if (popup !== null) { subscriptionPopups.current[provider] = popup try { popup.opener = null } catch { /* ignore */ } } setSubscriptionBusy(current => ({ ...current, [provider]: 'login' })) setSubscriptionMessage(current => ({ ...current, [provider]: '' })) try { const response = await fetch(SUBSCRIPTION_API.login, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider }), }) const body = await response.json() as { ok?: boolean; url?: string; code?: string; expiresInSeconds?: number; message?: string } if (!response.ok || body.ok !== true || typeof body.url !== 'string') throw new Error(body.message ?? `HTTP ${response.status}`) if (popup !== null) popup.location.replace(body.url) else window.open(body.url, '_blank') if (typeof body.code === 'string' && body.code !== '') { setSubscriptionMessage(current => ({ ...current, [provider]: t('settings.subscriptionDeviceCode', { code: body.code ?? '' }) })) } setSubscriptionPending(current => current.includes(provider) ? current : [...current, provider]) } catch (error) { popup?.close() delete subscriptionPopups.current[provider] setSubscriptionMessage(current => ({ ...current, [provider]: error instanceof Error ? error.message : String(error) })) } finally { setSubscriptionBusy(current => { const next = { ...current }; delete next[provider]; return next }) } } const subscriptionComplete = async (provider: SubscriptionProvider, input: string): Promise => { setSubscriptionBusy(current => ({ ...current, [provider]: 'login' })) setSubscriptionMessage(current => ({ ...current, [provider]: '' })) try { const response = await fetch(SUBSCRIPTION_API.login, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider, action: 'complete', input }), }) const body = await response.json() as { ok?: boolean; message?: string } if (!response.ok || body.ok !== true) throw new Error(body.message ?? `HTTP ${response.status}`) await refreshSubscriptions() const popup = subscriptionPopups.current[provider] if (popup !== undefined) { try { popup.close() } catch { /* ignore */ } delete subscriptionPopups.current[provider] } setSubscriptionPending(current => current.filter(item => item !== provider)) setSubscriptionMessage(current => ({ ...current, [provider]: t('settings.subscriptionLoginOk') })) } catch (error) { setSubscriptionMessage(current => ({ ...current, [provider]: error instanceof Error ? error.message : String(error) })) } finally { setSubscriptionBusy(current => { const next = { ...current }; delete next[provider]; return next }) } } const subscriptionLogout = async (provider: SubscriptionProvider): Promise => { setSubscriptionBusy(current => ({ ...current, [provider]: 'logout' })) setSubscriptionMessage(current => ({ ...current, [provider]: '' })) try { const response = await fetch(SUBSCRIPTION_API.login, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider, action: 'logout' }), }) const body = await response.json() as { ok?: boolean; message?: string } if (!response.ok || body.ok !== true) throw new Error(body.message ?? `HTTP ${response.status}`) const popup = subscriptionPopups.current[provider] if (popup !== undefined) { try { popup.close() } catch { /* ignore */ } delete subscriptionPopups.current[provider] } setSubscriptionStatus(current => ({ ...current, [provider]: { state: 'logged-out' } })) setSubscriptionPending(current => current.filter(item => item !== provider)) } catch (error) { setSubscriptionMessage(current => ({ ...current, [provider]: error instanceof Error ? error.message : String(error) })) } finally { setSubscriptionBusy(current => { const next = { ...current }; delete next[provider]; return next }) } } // Usage counters: refreshed once per card open (and after a successful save). useEffect(() => { if (!state.exposed) return let alive = true void fetch(USAGE_API, { method: 'POST' }) .then(async response => { const body = await response.json() as { ok?: boolean; usage?: UsageCounters }; if (alive && body.ok === true && body.usage !== undefined) setUsage(body.usage) }) .catch(() => { /* counters are best-effort */ }) return () => { alive = false } }, [state.exposed]) if (!state.available) return null const title = t('settings.title') const blocked = !state.dirty || state.invalid || state.saving || state.channels.saving const disabled = !state.writable const fieldProps = { overriddenLabel: t('settings.overridden'), resetLabel: t('settings.reset'), invalidLabel: t('settings.invalidNumber'), disabled, } if (!state.exposed) { return (
{open ? (

{t('settings.notExposed')}

) : null}
) } const channels = state.channels.channels const modelGroups = channels .map(channel => ({ id: channel.id, name: channel.name.trim() || channel.id, models: [...new Set(channel.models.map(model => model.alias).filter(alias => alias !== ''))] })) .filter(group => group.models.length > 0) const modelAliases = [...new Set(modelGroups.flatMap(group => group.models))] const editing = editingId === null ? undefined : channels.find(channel => channel.id === editingId) const readyChannels = channels.filter(channel => state.channels.keySet[channel.id] === true && channel.models.length > 0).length const totalModels = channels.reduce((total, channel) => total + channel.models.length, 0) const channelSummary = channels.length === 0 ? t('settings.summaryNotConfigured') : t('settings.summaryChannels', { ready: readyChannels, total: channels.length }) const promptSummary = state.promptModel.text.trim() || t('settings.summaryOptional') const storageSummary = state.storageEnabled.text === 'true' ? t('settings.summaryEnabled') : t('settings.summaryDisabled') const skillsSummary = state.skillsEnabled.text === 'false' ? t('settings.summaryDisabled') : t('settings.summaryEnabled') const generalSummary = state.enabled.text === 'false' ? t('settings.summaryDisabled') : t('settings.summaryEnabled') const overallReady = readyChannels > 0 const headerDescription = channels.length === 0 ? t('settings.headerNeedsChannel') : t('settings.headerSummary', { channels: channels.length, models: totalModels }) const summaryItems: Array<{ id: Exclude; label: string; value: string; tone?: 'neutral' | 'ready' | 'attention' }> = [ { id: 'channels', label: t('settings.summaryChannelsLabel'), value: channelSummary, tone: readyChannels > 0 ? 'ready' : 'attention' }, { id: 'prompt', label: t('settings.summaryPromptLabel'), value: promptSummary }, { id: 'storage', label: t('settings.summaryStorageLabel'), value: storageSummary, tone: state.storageEnabled.text === 'true' ? 'ready' : 'neutral' }, { id: 'ppt', label: t('ppt.nav'), value: 'PPTX' }, { id: 'skills', label: t('settings.summarySkillsLabel'), value: skillsSummary, tone: state.skillsEnabled.text === 'false' ? 'neutral' : 'ready' }, { id: 'general', label: t('settings.summaryGeneralLabel'), value: generalSummary, tone: state.enabled.text === 'false' ? 'attention' : 'ready' }, ] return (
{open ? (
{!state.writable ?

{t('settings.readOnly')}

: null}

{t('settings.summaryTitle')}

{t('settings.summaryHint')}

{overallReady ? t('settings.summaryReady') : t('settings.summaryNeedsSetup')}
{summaryItems.map(item => ( ))}
0 ? 'ready' : 'attention'} expanded={activeSection === 'channels'} onToggle={() => { setActiveSection(activeSection === 'channels' ? null : 'channels') }} > {channels.length === 0 ?

{t('channels.empty')}

: (
    {channels.map(channel => { const keyHeld = state.channels.keySet[channel.id] === true const subscriptionProvider = isSubscriptionProvider(channel.subscription) ? channel.subscription : undefined const subscriptionState = subscriptionProvider === undefined ? undefined : subscriptionStatus[subscriptionProvider] const ready = subscriptionProvider === undefined ? keyHeld && channel.models.length > 0 : subscriptionState?.state === 'logged-in' const isDefault = channel.id === state.channels.defaultChannelId if (confirmDeleteId === channel.id) { return (
  • {t('channels.deleteConfirmTitle', { name: channel.name || t('channels.untitled') })}
  • ) } return (
  • ) })}
)}
{open && presetPickerOpen ? ( { setPresetError(null) void fetch(PRESETS_API, { method: 'POST' }) .then(async response => { const body = await response.json() as { ok?: boolean; presets?: PresetProviderView[]; message?: string } if (!response.ok || body.ok !== true || body.presets === undefined) throw new Error(body.message ?? `HTTP ${response.status}`) setPresets(body.presets) }) .catch(error => { setPresetError(error instanceof Error ? error.message : String(error)) }) }} onPick={(preset) => { const existing = preset.subscription === undefined ? undefined : channels.find(channel => channel.subscription === preset.subscription) const draft = existing ?? newChannelDraft(preset) if (existing === undefined) props.channels.setChannels([...channels, draft]) setPresetPickerOpen(false) setEditingId(draft.id) if (preset.subscription !== undefined && subscriptionStatus[preset.subscription].state !== 'logged-in') { void subscriptionLogin(preset.subscription) } }} onCustom={() => { const draft = newChannelDraft(undefined) props.channels.setChannels([...channels, draft]) setPresetPickerOpen(false) setEditingId(draft.id) }} onClose={() => { setPresetPickerOpen(false) }} /> ) : null} {modelAliases.length > 0 ? (
) : null}
{ setActiveSection(activeSection === 'prompt' ? null : 'prompt') }} >

{t('settings.promptModel')}

{t('settings.promptModelDetectionHint')}

{state.promptModel.text.trim() !== '' ? ( {state.promptModel.text} ) : null}
{manualPromptModelOpen ? (
{ setManualPromptModel(event.target.value) }} />
) : null} {promptModels.length > 0 ? (
{t('settings.promptModelsCandidates')} {promptModels.map(candidate => ( ))}
) : null} {promptModelsError !== null ?

{promptModelsError}

: null} {promptApiOpen ?
{ props.edit('promptApiUrl', text) }} onReset={() => { props.resetField('promptApiUrl') }} /> { props.edit('promptApiKey', text) }} onReset={() => { props.resetField('promptApiKey') }} />
: null}
{ setActiveSection(activeSection === 'storage' ? null : 'storage') }} > { props.edit('localStoragePath', text) }} onReset={() => { props.resetField('localStoragePath') }} /> { props.edit('storageEnabled', text) }} onReset={() => { props.resetField('storageEnabled') }} /> { props.edit('storageEndpoint', text) }} onReset={() => { props.resetField('storageEndpoint') }} /> { props.edit('storageRegion', text) }} onReset={() => { props.resetField('storageRegion') }} /> { props.edit('storagePrefix', text) }} onReset={() => { props.resetField('storagePrefix') }} /> { props.edit('storageAccessKey', text) }} onReset={() => { props.resetField('storageAccessKey') }} /> { props.edit('storageSecretKey', text) }} onReset={() => { props.resetField('storageSecretKey') }} /> { props.edit('storageSyncGallery', text) }} onReset={() => { props.resetField('storageSyncGallery') }} /> { props.edit('storageSyncHistory', text) }} onReset={() => { props.resetField('storageSyncHistory') }} />
{storageTestResult !== null ?

{storageTestResult}

: null}

{t('settings.storageKeyHint')}

setActiveSection(activeSection === 'ppt' ? null : 'ppt')}> { setActiveSection(activeSection === 'skills' ? null : 'skills') }} > { props.edit('skillsEnabled', text) }} onReset={() => { props.resetField('skillsEnabled') }} /> { props.edit('allowHeavySkills', text) }} onReset={() => { props.resetField('allowHeavySkills') }} /> { props.edit('skillAllowlist', text) }} onReset={() => { props.resetField('skillAllowlist') }} /> { props.edit('skillOutputDir', text) }} onReset={() => { props.resetField('skillOutputDir') }} /> { props.edit('skillHeavyTimeoutMinutes', text) }} onReset={() => { props.resetField('skillHeavyTimeoutMinutes') }} /> { props.edit('skillAgentPreset', text) }} onReset={() => { props.resetField('skillAgentPreset') }} />
{skillProbeResult !== null ?

{skillProbeResult}

: null}
{ setActiveSection(activeSection === 'general' ? null : 'general') }} > { props.edit('enabled', text) }} onReset={() => { props.resetField('enabled') }} /> { props.edit('announceToAgent', text) }} onReset={() => { props.resetField('announceToAgent') }} /> { props.edit('allowAgentImageGeneration', text) }} onReset={() => { props.resetField('allowAgentImageGeneration') }} />
{(state.failed || state.channels.failed) ?

{t('settings.saveFailed')}

: null}
) : null} {open && editing !== undefined ? ( channel.id !== editing.id)} isDefault={editing.id === state.channels.defaultChannelId} writable={state.writable} onPatch={(patch) => { replaceChannel(channels, editing.id, patch, props.channels) }} onSetModels={(models) => { props.channels.setChannels(channels.map(channel => channel.id === editing.id ? { ...channel, models } : channel)) }} onSetKey={(value) => { props.channels.setChannelKey(editing.id, value) }} subscriptionStatus={editing.subscription === undefined ? undefined : subscriptionStatus[editing.subscription]} subscriptionBusy={editing.subscription === undefined ? undefined : subscriptionBusy[editing.subscription]} subscriptionMessage={editing.subscription === undefined ? undefined : subscriptionMessage[editing.subscription]} onSubscriptionLogin={(provider) => { void subscriptionLogin(provider) }} onSubscriptionComplete={(provider, input) => { void subscriptionComplete(provider, input) }} onSubscriptionLogout={(provider) => { void subscriptionLogout(provider) }} onSetDefault={() => { props.channels.setDefaultChannel(editing.id) }} onRemove={() => { props.channels.setChannels(channels.filter(channel => channel.id !== editing.id)); if (editing.id === state.channels.defaultChannelId && channels.length > 1) { const next = channels.find(channel => channel.id !== editing.id); if (next !== undefined) props.channels.setDefaultChannel(next.id) } setEditingId(null) }} onClose={() => { setEditingId(null) }} /> ) : null}
) } /** Channel row + dialog helpers -------------------------------------------------- */ function newChannelDraft(preset: PresetProviderView | undefined): ChannelDraft { return { id: clientId(), preset: preset?.id ?? '', name: preset?.name ?? '', apiUrl: preset?.apiUrl ?? '', apiUrlFull: false, protocol: 'auto', ...preset?.subscription === undefined ? {} : { auth: 'subscription' as const, subscription: preset.subscription }, models: (preset?.models ?? []).map(model => ({ ...model })), } } function addCustomChannel(channels: ChannelDraft[], form: ChannelsFormActions, openEditor: (id: string) => void): void { const draft = newChannelDraft(undefined) form.setChannels([...channels, draft]) openEditor(draft.id) } /** Patch one field (or models) of one staged channel. */ function replaceChannel(channels: ChannelDraft[], id: string, patch: Partial, form: ChannelsFormActions): void { form.setChannels(channels.map(channel => channel.id === id ? { ...channel, ...patch } : channel)) } function clientId(): string { const random = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : undefined return random ?? `ch-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` } /** Built-in provider picker, expanded inside the settings card. */ function PresetPicker(props: { t: (key: ImageGenKey, params?: Record) => string presets: PresetProviderView[] error: string | null disabled: boolean onLoad: () => void onPick: (preset: PresetProviderView) => void onCustom: () => void onClose: () => void }) { const { t } = props const loadedRef = useRef(false) const [query, setQuery] = useState('') const [tab, setTab] = useState<'api-key' | 'subscription'>('api-key') useEffect(() => { if (loadedRef.current) return loadedRef.current = true props.onLoad() }, []) useEffect(() => { const onKeyDown = (event: KeyboardEvent): void => { if (event.key === 'Escape') props.onClose() } window.addEventListener('keydown', onKeyDown) return () => { window.removeEventListener('keydown', onKeyDown) } }, [props.onClose]) const tabPresets = props.presets.filter(preset => tab === 'subscription' ? preset.subscription !== undefined : preset.subscription === undefined) const needle = query.trim().toLowerCase() const visiblePresets = needle === '' ? tabPresets : tabPresets.filter(preset => preset.name.toLowerCase().includes(needle) || preset.models.some(model => model.alias.toLowerCase().includes(needle) || model.id.toLowerCase().includes(needle)), ) return (
{ if (event.target === event.currentTarget) props.onClose() }} >

{t('channels.presetPickerTitle')}

{t(tab === 'subscription' ? 'channels.presetPickerSubscriptionHint' : 'channels.presetPickerApiKeyHint')}

{ setQuery(event.target.value) }} />
{visiblePresets.map(preset => ( ))} {visiblePresets.length === 0 && tabPresets.length > 0 ?

{t('channels.presetEmpty')}

: null} {tab === 'api-key' ? : null} {props.error !== null ?

{t('channels.presetLoadFailed', { error: props.error })}

: null}
) } /** Channel editor (modal): key, display name, API URL, model catalog. */ function ChannelEditor(props: { t: (key: ImageGenKey, params?: Record) => string channel: ChannelDraft keyHeld: boolean usage: UsageCounters | null otherChannels: ChannelDraft[] isDefault: boolean writable: boolean onPatch: (patch: Partial) => void onSetModels: (models: ModelMapping[]) => void onSetKey: (value: string | undefined) => void subscriptionStatus?: SubscriptionStatus subscriptionBusy?: 'login' | 'logout' subscriptionMessage?: string onSubscriptionLogin: (provider: SubscriptionProvider) => void onSubscriptionComplete: (provider: SubscriptionProvider, input: string) => void onSubscriptionLogout: (provider: SubscriptionProvider) => void onSetDefault: () => void onRemove: () => void onClose: () => void }) { const { t, channel } = props const subscription = isSubscriptionProvider(channel.subscription) ? channel.subscription : undefined const subscriptionMode = channel.auth === 'subscription' && subscription !== undefined const [keyDraft, setKeyDraft] = useState('') const [candidates, setCandidates] = useState(null) const [detecting, setDetecting] = useState(false) const [detectError, setDetectError] = useState(null) const [manualId, setManualId] = useState('') const [removeOpen, setRemoveOpen] = useState(false) const [copyFrom, setCopyFrom] = useState('') const [manualAuthInput, setManualAuthInput] = useState('') const [showKey, setShowKey] = useState(false) const [editingModelIndex, setEditingModelIndex] = useState(null) const generatedCount = (alias: string): number => { if (props.usage === null) return 0 const channelBucket = props.usage.byChannel[channel.id] ?? props.usage.byChannel[`name:${channel.name}`] ?? {} return channelBucket[alias] ?? props.usage.totals[alias] ?? 0 } const detect = (): void => { if (channel.apiUrlFull && resolveChannelProtocol(channel.apiUrl, channel.protocol) !== 'chat-completions') return setDetecting(true) setDetectError(null) const payload: Record = { channelId: channel.id, apiUrlFull: channel.apiUrlFull, protocol: channel.protocol ?? 'auto', } if (channel.apiUrl.trim() !== '') payload.apiUrl = channel.apiUrl.trim() if (keyDraft.trim() !== '') payload.apiKey = keyDraft.trim() void fetch(IMAGE_MODEL_API.models, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }) .then(async response => { const body = await response.json() as { ok?: boolean; models?: string[]; message?: string } if (!response.ok || body.ok !== true) throw new Error(body.message ?? `HTTP ${response.status}`) setCandidates(body.models ?? []) }) .catch(error => { setDetectError(error instanceof Error ? error.message : String(error)) }) .finally(() => { setDetecting(false) }) } // Auto-detect once when the dialog opens with a complete endpoint. const autoDetected = useRef(false) useEffect(() => { if (autoDetected.current) return autoDetected.current = true if (!subscriptionMode && (!channel.apiUrlFull || resolveChannelProtocol(channel.apiUrl, channel.protocol) === 'chat-completions') && channel.apiUrl.trim() !== '' && (props.keyHeld || keyDraft.trim() !== '')) detect() }, []) const addManual = (): void => { const id = manualId.trim() if (id === '') return const next = [...channel.models] const alias = id if (!next.some(model => model.alias === alias)) next.push({ alias, id }) props.onSetModels(next) setManualId('') } const copyFromChannel = (): void => { const source = props.otherChannels.find(chance => chance.id === copyFrom) if (source === undefined) return const merged = [...channel.models] for (const model of source.models) { const alias = model.alias // Copy with a collision suffix so both sources stay selectable. let unique = alias let suffix = 2 while (merged.some(entry => entry.alias === unique)) unique = `${alias} (${suffix++})` merged.push({ alias: unique, id: model.id }) } props.onSetModels(merged) setCopyFrom('') } return (
{ event.stopPropagation() }}>

{t('channels.editorTitle')} · {channel.name || t('channels.untitled')}

{t('channels.editorSaveNote')}

{ props.onPatch({ name: event.target.value }) }} />
{subscriptionMode ? (
{EXPERIMENTAL_SUBSCRIPTION_PROVIDERS.has(subscription) ?

{t('settings.subscriptionExperimental')}

: null}
{props.subscriptionStatus?.state === 'logged-in' ? : }
{props.subscriptionMessage ?

{props.subscriptionMessage}

: null} {props.subscriptionStatus?.state !== 'logged-in' ?

{t('settings.subscriptionManualHint')}

{ setManualAuthInput(event.target.value) }} />
: null}

{t('settings.subscriptionIsolationHint')}

) : <>
{ const apiUrl = event.target.value props.onPatch({ apiUrl, ...isChatCompletionsUrl(apiUrl) ? { apiUrlFull: true } : {} }) }} />

{t('channels.protocolHint')}

{channel.apiUrlFull ?

{t('channels.apiUrlFullHint')}

: null}
{props.keyHeld || keyDraft !== '' ? t('channels.keySet') : t('channels.keyMissing')} {props.keyHeld || keyDraft !== '' ? ( ) : null}
{ const value = event.target.value; setKeyDraft(value); props.onSetKey(value === '' ? undefined : value) }} />

{t('channels.modelCatalogTitle')}

{detectError !== null ?

{t('channels.detectFailed', { error: detectError })}

: null} {candidates !== null && detectError === null ?

{t('channels.detectSuccess', { n: candidates.length })}

: null} {channel.models.length === 0 ?

{t('channels.noModelsHint')}

: (
    {channel.models.map((model, index) => { const entry = describeModel(model.id || model.alias) const generated = generatedCount(model.alias) return (
  • {editingModelIndex === index ? (
    { const next = [...channel.models] next[index] = { ...model, alias: event.target.value } props.onSetModels(next) }} /> → { const next = [...channel.models] next[index] = { ...model, id: event.target.value } props.onSetModels(next) }} />
    ) : (
    {model.alias} {entry.labelZh}{entry.known ? '' : ` · ${t('channels.unknownProtocol')}`} {generated > 0 ? {t('channels.generated', { n: generated })} : null}
    {model.id !== model.alias ? → {model.id} : null}
    )}
    {editingModelIndex === index ? null : ( )}
  • ) })}
)} {candidates !== null && candidates.length > 0 ? (
{t('channels.candidatesTitle')} {candidates.map(candidate => { const selected = channel.models.some(model => model.alias === candidate) const entry = describeModel(candidate) return ( ) })}
) : null}
{ setManualId(event.target.value) }} onKeyDown={event => { if (event.key === 'Enter') { event.preventDefault(); addManual() } }} />
{props.otherChannels.length > 0 ? (
) : null}
}
{props.isDefault ? {t('channels.defaultLabel')} : ( )} {removeOpen ? ( <> ) : ( )}
) } /** Props every field control needs regardless of its value type. */ interface FieldProps { /** Stable id associating the label with its control. */ id: string /** Visible label. */ label: string /** One-line explanation rendered under the control. */ hint: string /** Draft text this control renders. */ text: string /** True when saving would leave a user-layer entry for this field. */ overridden: boolean /** True when the draft is not a value this field accepts. */ invalid: boolean /** Copy for the overridden badge. */ overriddenLabel: string /** Copy for the reset control. */ resetLabel: string /** Copy shown in place of the hint while the draft is invalid. */ invalidLabel: string /** Disables every control (read-only document, or an unavailable namespace). */ disabled: boolean /** Stage draft text. */ onEdit: (text: string) => void /** Stage a clear so the field re-inherits the composition layer. */ onReset: () => void } /** A staged value field; `secret` renders a password control. */ function ValueField(props: FieldProps & { /** Render a password control. */ secret?: boolean /** Placeholder shown while the draft is empty. */ placeholder?: string /** Label of the dedicated clear control (secret fields). */ clearLabel?: string /** Stage a clear of the stored secret. */ onClear?: () => void /** Whether a stored secret exists (enables the clear control). */ canClear?: boolean }) { return (
{props.overridden ? ( {props.overriddenLabel} ) : null} {props.secret === true && props.canClear === true ? ( ) : null}
{ props.onEdit(event.target.value) }} />

{props.invalid ? props.invalidLabel : props.hint}

) } /** A staged boolean field: 继承 / 开 / 关. */ function BooleanField(props: FieldProps & { /** Copy for the inherit option. */ inheritLabel: string /** Copy for the on option. */ onLabel: string /** Copy for the off option. */ offLabel: string }) { return (
{props.overridden ? ( {props.overriddenLabel} ) : null}

{props.hint}

) }