Agent replies that produced HTML could only be read as source, so users had to copy the markup into an external file to see the result. Completed Agent and Subagent output now renders complete HTML documents, single-root HTML replies, and `html` / `htm` code blocks as an isolated static preview in place. Each preview keeps a persistent notice that scripts and network are disabled and offers the same icon actions as Mermaid diagrams: a source toggle and a full-screen preview. The new "render HTML in conversations" switch under Platform Features / General is on by default, applies to the open conversation immediately, and falls back to HTML source when disabled. Previews cannot run scripts, reach the network, submit forms, open windows, or load remote media; author stylesheets and inline styles are preserved so the intended layout survives, and remote resource loading is refused by the preview CSP instead. Direct-model replies no longer fail once they grow large. The byte caps on streaming responses, single stream blocks, non-streaming replies, model tool arguments, tool request context, aggregate tool results, and accumulated answer text are removed, so users stop seeing "模型接口流式响应超过安全限制" on long answers. Response length is now governed by the request timeout, cancellation, and context compression. Image generation responses keep their decodable-image validation. A message sent after a failed or interrupted run stayed in the pending queue instead of running. The conversation is now released whenever a run reaches any terminal state, not only when the renderer reports readiness again, and a dispatched queue item whose run never starts releases its reservation and returns to pending. Later messages dispatch without extra user action. Release note: Agent 回复中的 HTML 现在可直接在会话内预览,支持查看源码和全屏预览,默认开启并可在“平台功能 / 通用设置”关闭;直连模型的长回复不再因大小限制中断;上一次运行失败或被中断后,新发送的消息会立即执行而不再排队等待。
4101 lines
146 KiB
TypeScript
4101 lines
146 KiB
TypeScript
import {
|
|
FolderOpen,
|
|
KeyRound,
|
|
LockKeyhole,
|
|
Plus,
|
|
Save,
|
|
SunMoon,
|
|
TerminalSquare,
|
|
Trash2,
|
|
X
|
|
} from 'lucide-react'
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState
|
|
} from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import type {
|
|
AssistantExpert,
|
|
AssistantProject,
|
|
ProjectCreateInput,
|
|
ProjectChannel
|
|
} from '../../shared/assistant-contracts'
|
|
import type {
|
|
AgentRuntimeDetection,
|
|
ContextCompressionSettings,
|
|
RuntimeConfigActionInput,
|
|
RuntimeFileSelectionKind,
|
|
RuntimeSettings,
|
|
RuntimeSettingsInput,
|
|
RuntimeModelSource
|
|
} from '../../shared/contracts'
|
|
import {
|
|
defaultContextCompressionSettings,
|
|
defaultAnthropicMaximumOutputTokens,
|
|
builtinEmbeddingConnectionId,
|
|
defaultModelProfileId as builtInDefaultModelProfileId,
|
|
defaultRuntimeSettings,
|
|
isAgentRuntimeModelProtocol,
|
|
isDeepSeekHarnessModelProfile,
|
|
MODEL_REQUEST_CUSTOMIZATION_LIMITS,
|
|
modelRequestBodySchema,
|
|
modelRequestHeadersSchema
|
|
} from '../../shared/contracts'
|
|
import { RolePromptSettingsSection } from './RolePromptSettingsSection'
|
|
import { CapabilitiesAndToolsSettingsSection } from './CapabilitiesAndToolsSettingsSection'
|
|
import { ChannelSettingsSection } from './ChannelSettingsSection'
|
|
import { UpdateSettingsSection } from './UpdateSettingsSection'
|
|
import { PlatformFeaturesSettingsSection } from './PlatformFeaturesSettingsSection'
|
|
import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
|
|
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
|
|
import { DshMarketplaceSection } from './DshMarketplaceSection'
|
|
import { displayErrorMessage } from './error-message'
|
|
import { SshHostsSettingsSection } from './SshHostsSettingsSection'
|
|
import {
|
|
RuntimeCustomizationSection,
|
|
type RuntimeCustomizationSectionHandle
|
|
} from './RuntimeCustomizationSection'
|
|
import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
|
|
import {
|
|
SettingsCategoryHeader,
|
|
SettingsWarningList
|
|
} from './SettingsPrimitives'
|
|
import {
|
|
getSettingsCategoryList,
|
|
type SettingsCategoryId
|
|
} from './settings-categories'
|
|
import type { AppearanceTheme } from './theme'
|
|
import type { AppNotificationInput } from './notifications'
|
|
import type {
|
|
EmbeddingDiagnosticResult,
|
|
EmbeddingModelSnapshot,
|
|
EmbeddingSettingsSnapshot
|
|
} from '../../shared/embedding-contracts'
|
|
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
|
|
import { useUiLocale } from './i18n/UiLocaleProvider'
|
|
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
|
|
import { findPreferredCompatibleModelProfile } from './model-profile-selection'
|
|
import { BrandingSettingsSection } from './BrandingSettingsSection'
|
|
import {
|
|
defaultBrandingPreferences,
|
|
type BrandingPreferences
|
|
} from './branding'
|
|
|
|
type ModelType = 'llm' | 'embedding' | 'rerank' | 'speech'
|
|
type AgentRuntimeType =
|
|
| RuntimeConfigActionInput['runtime']
|
|
| 'deepseek-harness'
|
|
type ModelProfileDraft = Omit<
|
|
RuntimeSettings['modelProfiles'][number],
|
|
'requestHeaders' | 'requestBody'
|
|
> & {
|
|
supportsImageInput: boolean
|
|
requestHeadersText: string
|
|
requestBodyText: string
|
|
apiKey: string
|
|
clearApiKey: boolean
|
|
}
|
|
type EmbeddingConnectionDraft =
|
|
Omit<
|
|
Exclude<
|
|
NonNullable<RuntimeSettings['embeddingConnections']>[number],
|
|
{ kind: 'builtin' }
|
|
>,
|
|
'apiKey'
|
|
> & {
|
|
apiKey: string
|
|
clearApiKey: boolean
|
|
}
|
|
|
|
function toEmbeddingConnectionDrafts(
|
|
settings: RuntimeSettings
|
|
): EmbeddingConnectionDraft[] {
|
|
return (settings.embeddingConnections ?? [])
|
|
.filter(
|
|
(
|
|
connection
|
|
): connection is Exclude<
|
|
NonNullable<RuntimeSettings['embeddingConnections']>[number],
|
|
{ kind: 'builtin' }
|
|
> => connection.kind === 'openai-compatible'
|
|
)
|
|
.map((connection) => ({
|
|
...connection,
|
|
apiKey: '',
|
|
clearApiKey: false
|
|
}))
|
|
}
|
|
|
|
const contextCompressionTokenScale = 1_000
|
|
const minimumContextCompressionTriggerThousands = 8
|
|
const maximumContextCompressionTriggerThousands = 1_000
|
|
const minimumContextCompressionRecentRawThousands = 4
|
|
const maximumContextCompressionRecentRawThousands = 256
|
|
|
|
type ContextCompressionTokenDrafts = {
|
|
trigger: string
|
|
recentRaw: string
|
|
}
|
|
|
|
function contextCompressionTokenDrafts(
|
|
settings: ContextCompressionSettings
|
|
): ContextCompressionTokenDrafts {
|
|
return {
|
|
trigger: String(
|
|
settings.triggerTokens / contextCompressionTokenScale
|
|
),
|
|
recentRaw: String(
|
|
settings.recentRawTokens / contextCompressionTokenScale
|
|
)
|
|
}
|
|
}
|
|
|
|
function parseContextCompressionTokenDraft(
|
|
value: string
|
|
): number | undefined {
|
|
if (!value.trim()) {
|
|
return undefined
|
|
}
|
|
const parsed = Number(value)
|
|
return Number.isFinite(parsed)
|
|
? Math.round(parsed * contextCompressionTokenScale)
|
|
: undefined
|
|
}
|
|
|
|
function maximumContextCompressionRecentRawThousandsFor(
|
|
triggerTokens: number
|
|
): number {
|
|
return Math.max(
|
|
minimumContextCompressionRecentRawThousands,
|
|
Math.min(
|
|
maximumContextCompressionRecentRawThousands,
|
|
triggerTokens / contextCompressionTokenScale - 1
|
|
)
|
|
)
|
|
}
|
|
|
|
function normalizeContextCompressionTokenDrafts(
|
|
settings: ContextCompressionSettings,
|
|
drafts: ContextCompressionTokenDrafts
|
|
): ContextCompressionSettings {
|
|
const triggerTokens = Math.min(
|
|
maximumContextCompressionTriggerThousands *
|
|
contextCompressionTokenScale,
|
|
Math.max(
|
|
minimumContextCompressionTriggerThousands *
|
|
contextCompressionTokenScale,
|
|
parseContextCompressionTokenDraft(drafts.trigger) ??
|
|
settings.triggerTokens
|
|
)
|
|
)
|
|
const maximumRecentRawTokens =
|
|
maximumContextCompressionRecentRawThousandsFor(triggerTokens) *
|
|
contextCompressionTokenScale
|
|
const recentRawTokens = Math.min(
|
|
maximumRecentRawTokens,
|
|
Math.max(
|
|
minimumContextCompressionRecentRawThousands *
|
|
contextCompressionTokenScale,
|
|
parseContextCompressionTokenDraft(drafts.recentRaw) ??
|
|
settings.recentRawTokens
|
|
)
|
|
)
|
|
return {
|
|
...settings,
|
|
triggerTokens,
|
|
recentRawTokens
|
|
}
|
|
}
|
|
|
|
export type SettingsLeaveRequester = (proceed: () => void) => void
|
|
|
|
type SettingsPanelProps = {
|
|
open: boolean
|
|
presentation?: 'modal' | 'page'
|
|
initialCategory?: SettingsCategoryId
|
|
initialChannel?: ProjectChannel
|
|
onClose: () => void
|
|
onProjectsDeleted?: (projectIds: string[]) => void
|
|
onSaved: (settings: RuntimeSettings) => void
|
|
onNotify?: (notification: AppNotificationInput) => void
|
|
onUpdateProject: (
|
|
projectId: string,
|
|
input: ProjectCreateInput
|
|
) => Promise<AssistantProject>
|
|
projects: AssistantProject[]
|
|
onExpertsChanged?: (experts: AssistantExpert[]) => void
|
|
onClearLocalData: () => Promise<void>
|
|
appearanceTheme?: AppearanceTheme
|
|
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
|
|
brandingPreferences?: BrandingPreferences
|
|
brandingFallbackLogo?: string
|
|
onBrandingPreferencesChange?: (
|
|
preferences: BrandingPreferences
|
|
) => boolean
|
|
magicNotesEnabled?: boolean
|
|
remoteProjectsEnabled?: boolean
|
|
onConversationHtmlRenderingEnabledChange?: (
|
|
enabled: boolean
|
|
) => void
|
|
onMagicNotesEnabledChange?: (enabled: boolean) => void
|
|
onRemoteProjectsEnabledChange?: (enabled: boolean) => void
|
|
onMagicNotesShowIncompleteTodoCountChange?: (
|
|
enabled: boolean
|
|
) => void
|
|
onShortcutSettingsChanged?: (
|
|
snapshot: GlobalShortcutSettingsSnapshot
|
|
) => void
|
|
onLeaveRequestReady?: (
|
|
requester: SettingsLeaveRequester | undefined
|
|
) => void
|
|
}
|
|
|
|
function settingsErrorMessage(reason: unknown, fallback: string): string {
|
|
const message = displayErrorMessage(reason, fallback)
|
|
try {
|
|
const issues: unknown = JSON.parse(message)
|
|
if (
|
|
Array.isArray(issues) &&
|
|
typeof issues[0] === 'object' &&
|
|
issues[0] !== null &&
|
|
'message' in issues[0] &&
|
|
typeof issues[0].message === 'string'
|
|
) {
|
|
return issues[0].message
|
|
}
|
|
} catch {
|
|
// Keep provider and local errors as-is when they are not validation JSON.
|
|
}
|
|
return message
|
|
}
|
|
|
|
function toModelProfileDrafts(
|
|
settings: RuntimeSettings
|
|
): ModelProfileDraft[] {
|
|
return settings.modelProfiles.map(
|
|
({ requestHeaders, requestBody, ...profile }) => ({
|
|
...profile,
|
|
supportsImageInput: profile.supportsImageInput ?? false,
|
|
maximumOutputTokens:
|
|
profile.maximumOutputTokens ??
|
|
defaultAnthropicMaximumOutputTokens,
|
|
requestHeadersText: JSON.stringify(
|
|
requestHeaders ?? {},
|
|
null,
|
|
2
|
|
),
|
|
requestBodyText: JSON.stringify(requestBody ?? {}, null, 2),
|
|
apiKey: '',
|
|
clearApiKey: false
|
|
})
|
|
)
|
|
}
|
|
|
|
const maximumRequestHeadersDraftCharacters =
|
|
MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumHeaders *
|
|
(MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumHeaderNameLength +
|
|
MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumHeaderValueLength *
|
|
2 +
|
|
16) +
|
|
2
|
|
const maximumRequestBodyDraftCharacters =
|
|
MODEL_REQUEST_CUSTOMIZATION_LIMITS.maximumBodyBytes * 4
|
|
|
|
function parseRequestHeadersDraft(value: string) {
|
|
if (value.length > maximumRequestHeadersDraftCharacters) {
|
|
return { success: false } as const
|
|
}
|
|
try {
|
|
return modelRequestHeadersSchema.safeParse(JSON.parse(value))
|
|
} catch {
|
|
return { success: false } as const
|
|
}
|
|
}
|
|
|
|
function parseRequestBodyDraft(value: string) {
|
|
if (value.length > maximumRequestBodyDraftCharacters) {
|
|
return { success: false } as const
|
|
}
|
|
try {
|
|
return modelRequestBodySchema.safeParse(JSON.parse(value))
|
|
} catch {
|
|
return { success: false } as const
|
|
}
|
|
}
|
|
|
|
function configuredRuntimeSettings(
|
|
settings: RuntimeSettings
|
|
): NonNullable<RuntimeSettings['configured']> {
|
|
return settings.configured ?? {
|
|
modelProfiles: settings.modelProfiles,
|
|
opencodeBaseUrl: settings.opencodeBaseUrl,
|
|
opencodeBinaryPath: settings.opencodeBinaryPath,
|
|
opencodeConfigPath: settings.opencodeConfigPath,
|
|
continueBinaryPath: settings.continueBinaryPath,
|
|
continueConfigPath: settings.continueConfigPath,
|
|
workspacePath: settings.workspacePath,
|
|
opencodeModelSource: settings.opencodeModelSource,
|
|
continueModelSource: settings.continueModelSource,
|
|
deepseekHarnessModelSource:
|
|
settings.deepseekHarnessModelSource
|
|
}
|
|
}
|
|
|
|
type RuntimeDraftSelection =
|
|
| string
|
|
| ((selectedId: string) => string)
|
|
|
|
function hydrateRuntimeSettings(
|
|
value: RuntimeSettings,
|
|
setters: {
|
|
settings: (value: RuntimeSettings) => void
|
|
provider: (value: RuntimeSettings['provider']) => void
|
|
modelProfiles: (value: ModelProfileDraft[]) => void
|
|
selectedModelProfileId: (value: RuntimeDraftSelection) => void
|
|
defaultModelProfileId: (value: string) => void
|
|
opencodeModelSource: (value: RuntimeModelSource) => void
|
|
continueModelSource: (value: RuntimeModelSource) => void
|
|
deepseekHarnessModelSource: (value: RuntimeModelSource) => void
|
|
opencodeBaseUrl: (value: string) => void
|
|
opencodeBinaryPath: (value: string) => void
|
|
opencodeConfigPath: (value: string) => void
|
|
continueBinaryPath: (value: string) => void
|
|
continueConfigPath: (value: string) => void
|
|
continueMode: (value: RuntimeSettings['continueMode']) => void
|
|
knowledgeEmbeddingEnabled: (value: boolean) => void
|
|
knowledgeEmbeddingBaseUrl: (value: string) => void
|
|
knowledgeEmbeddingModel: (value: string) => void
|
|
knowledgeEmbeddingApiKey: (value: string) => void
|
|
clearKnowledgeEmbeddingApiKey: (value: boolean) => void
|
|
embeddingConnections: (value: EmbeddingConnectionDraft[]) => void
|
|
activeEmbeddingConnectionId: (value: string) => void
|
|
knowledgeRerankEnabled: (value: boolean) => void
|
|
knowledgeRerankEndpoint: (value: string) => void
|
|
knowledgeRerankModel: (value: string) => void
|
|
knowledgeRerankApiKey: (value: string) => void
|
|
clearKnowledgeRerankApiKey: (value: boolean) => void
|
|
workspacePath: (value: string) => void
|
|
toolApproval: (value: RuntimeSettingsInput['toolApproval']) => void
|
|
subagentSmartRoutingEnabled: (value: boolean) => void
|
|
contextCompression: (value: ContextCompressionSettings) => void
|
|
},
|
|
preserveSelectedProfile = false
|
|
): void {
|
|
const configured = configuredRuntimeSettings(value)
|
|
setters.settings(value)
|
|
setters.provider(value.provider)
|
|
setters.modelProfiles(toModelProfileDrafts(value))
|
|
const fallbackProfileId = value.modelProfiles.some(
|
|
(profile) => profile.id === value.defaultModelProfileId
|
|
)
|
|
? value.defaultModelProfileId
|
|
: value.modelProfiles[0]?.id ?? ''
|
|
setters.selectedModelProfileId(
|
|
preserveSelectedProfile
|
|
? (selectedId) =>
|
|
value.modelProfiles.some(
|
|
(profile) => profile.id === selectedId
|
|
)
|
|
? selectedId
|
|
: fallbackProfileId
|
|
: fallbackProfileId
|
|
)
|
|
setters.defaultModelProfileId(value.defaultModelProfileId)
|
|
setters.opencodeModelSource(configured.opencodeModelSource)
|
|
setters.continueModelSource(configured.continueModelSource)
|
|
setters.deepseekHarnessModelSource(
|
|
configured.deepseekHarnessModelSource ?? { kind: 'platform' }
|
|
)
|
|
setters.opencodeBaseUrl(configured.opencodeBaseUrl)
|
|
setters.opencodeBinaryPath(configured.opencodeBinaryPath)
|
|
setters.opencodeConfigPath(configured.opencodeConfigPath)
|
|
setters.continueBinaryPath(configured.continueBinaryPath)
|
|
setters.continueConfigPath(configured.continueConfigPath)
|
|
setters.continueMode(value.continueMode)
|
|
setters.knowledgeEmbeddingEnabled(value.knowledgeEmbeddingEnabled)
|
|
setters.knowledgeEmbeddingBaseUrl(value.knowledgeEmbeddingBaseUrl)
|
|
setters.knowledgeEmbeddingModel(value.knowledgeEmbeddingModel)
|
|
setters.knowledgeEmbeddingApiKey('')
|
|
setters.clearKnowledgeEmbeddingApiKey(false)
|
|
setters.embeddingConnections(toEmbeddingConnectionDrafts(value))
|
|
setters.activeEmbeddingConnectionId(
|
|
value.activeEmbeddingConnectionId ?? builtinEmbeddingConnectionId
|
|
)
|
|
setters.knowledgeRerankEnabled(
|
|
value.knowledgeRerankEnabled ??
|
|
defaultRuntimeSettings.knowledgeRerankEnabled
|
|
)
|
|
setters.knowledgeRerankEndpoint(
|
|
value.knowledgeRerankEndpoint ??
|
|
defaultRuntimeSettings.knowledgeRerankEndpoint
|
|
)
|
|
setters.knowledgeRerankModel(
|
|
value.knowledgeRerankModel ??
|
|
defaultRuntimeSettings.knowledgeRerankModel
|
|
)
|
|
setters.knowledgeRerankApiKey('')
|
|
setters.clearKnowledgeRerankApiKey(false)
|
|
setters.workspacePath(configured.workspacePath)
|
|
setters.toolApproval(
|
|
value.toolApproval === 'policy' ? 'policy' : 'always'
|
|
)
|
|
setters.subagentSmartRoutingEnabled(
|
|
value.subagentSmartRoutingEnabled
|
|
)
|
|
setters.contextCompression(
|
|
value.contextCompression ?? defaultContextCompressionSettings
|
|
)
|
|
}
|
|
|
|
type RuntimeConfigCardProps = {
|
|
runtime: RuntimeConfigActionInput['runtime']
|
|
runtimeLabel: string
|
|
description: string
|
|
fileKind: Extract<
|
|
RuntimeFileSelectionKind,
|
|
'opencodeConfig' | 'continueConfig'
|
|
>
|
|
path: string
|
|
savedPath?: string
|
|
onPathChange: (value: string) => void
|
|
onSelect: (
|
|
kind: RuntimeFileSelectionKind,
|
|
setValue: (value: string) => void
|
|
) => Promise<void>
|
|
onOpen: (
|
|
runtime: RuntimeConfigActionInput['runtime'],
|
|
action: RuntimeConfigActionInput['action']
|
|
) => Promise<void>
|
|
}
|
|
|
|
function RuntimeConfigCard({
|
|
runtime,
|
|
runtimeLabel,
|
|
description,
|
|
fileKind,
|
|
path,
|
|
savedPath,
|
|
onPathChange,
|
|
onSelect,
|
|
onOpen
|
|
}: RuntimeConfigCardProps): React.JSX.Element {
|
|
const { t } = useTranslation('settings')
|
|
const saved = Boolean(path) && savedPath === path
|
|
return (
|
|
<div className="runtime-config-card">
|
|
<div>
|
|
<strong>
|
|
{t('runtime.configCard.title', { runtime: runtimeLabel })}
|
|
</strong>
|
|
<small>{description}</small>
|
|
</div>
|
|
<label className="field">
|
|
<span>{t('runtime.configCard.fileLabel')}</span>
|
|
<div className="workspace-picker">
|
|
<input
|
|
aria-label={t('runtime.configCard.pathAriaLabel', {
|
|
runtime: runtimeLabel
|
|
})}
|
|
onChange={(event) => onPathChange(event.target.value)}
|
|
placeholder={t('runtime.configCard.pathPlaceholder', {
|
|
runtime: runtimeLabel
|
|
})}
|
|
value={path}
|
|
/>
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => void onSelect(fileKind, onPathChange)}
|
|
type="button"
|
|
>
|
|
{t('actions.selectFile')}
|
|
</button>
|
|
<button
|
|
className="secondary-button"
|
|
disabled={!path}
|
|
onClick={() => onPathChange('')}
|
|
type="button"
|
|
>
|
|
{t('actions.clear')}
|
|
</button>
|
|
</div>
|
|
</label>
|
|
<div className="runtime-config-actions">
|
|
{saved ? (
|
|
<>
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => void onOpen(runtime, 'open-file')}
|
|
type="button"
|
|
>
|
|
{t('actions.openConfigFile')}
|
|
</button>
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => void onOpen(runtime, 'show-file')}
|
|
type="button"
|
|
>
|
|
{t('actions.revealInFolder')}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => void onOpen(runtime, 'open-directory')}
|
|
type="button"
|
|
>
|
|
{t('actions.openConfigDirectory', {
|
|
runtime: runtimeLabel
|
|
})}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{path && !saved && (
|
|
<small className="runtime-config-card__hint">
|
|
{t('runtime.configCard.unsavedHint')}
|
|
</small>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RuntimeOverviewCard({
|
|
detection,
|
|
detectionLabel,
|
|
detecting,
|
|
modelConfiguration,
|
|
recommendation,
|
|
runtime
|
|
}: {
|
|
detection: AgentRuntimeDetection['opencode'] | undefined
|
|
detectionLabel: string
|
|
detecting: boolean
|
|
modelConfiguration: string
|
|
recommendation: string
|
|
runtime: string
|
|
}): React.JSX.Element {
|
|
const { t } = useTranslation('settings')
|
|
const localizedDetail =
|
|
detection?.available && detection.source
|
|
? t(`runtime.detection.details.${detection.source}`, {
|
|
runtime: detectionLabel,
|
|
versionSuffix: detection.version ? ` ${detection.version}` : ''
|
|
})
|
|
: detection?.detail
|
|
const status = detecting
|
|
? t('runtime.detection.detecting')
|
|
: detection?.available
|
|
? t('runtime.detection.ready')
|
|
: detection
|
|
? t('runtime.detection.unavailable')
|
|
: t('runtime.detection.notDetected')
|
|
|
|
return (
|
|
<div className="runtime-note runtime-overview">
|
|
<dl className="runtime-overview__details">
|
|
<dt>{t('runtime.runtimeLabel')}</dt>
|
|
<dd>{runtime}</dd>
|
|
<dt>{t('runtime.modelConfigurationLabel')}</dt>
|
|
<dd>{modelConfiguration}</dd>
|
|
<dt>{t('runtime.detection.statusLabel')}</dt>
|
|
<dd aria-atomic="true" aria-live="polite">
|
|
{status}
|
|
</dd>
|
|
{!detecting && detection?.available && (
|
|
<>
|
|
<dt>{t('runtime.detection.pathLabel')}</dt>
|
|
<dd className="runtime-overview__path">
|
|
{detection.path}
|
|
</dd>
|
|
{detection.version && (
|
|
<>
|
|
<dt>{t('runtime.detection.versionLabel')}</dt>
|
|
<dd>{detection.version}</dd>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
{!detecting && localizedDetail && (
|
|
<>
|
|
<dt>{t('runtime.detection.detailLabel')}</dt>
|
|
<dd>{localizedDetail}</dd>
|
|
</>
|
|
)}
|
|
</dl>
|
|
<p>{recommendation}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function SettingsPanel({
|
|
open,
|
|
presentation = 'modal',
|
|
initialCategory,
|
|
initialChannel,
|
|
onClose,
|
|
onProjectsDeleted = () => {},
|
|
onSaved,
|
|
onNotify = () => {},
|
|
onUpdateProject,
|
|
projects,
|
|
onClearLocalData,
|
|
onExpertsChanged = () => {},
|
|
appearanceTheme = 'system',
|
|
onAppearanceThemeChange = () => {},
|
|
brandingPreferences = defaultBrandingPreferences,
|
|
brandingFallbackLogo,
|
|
onBrandingPreferencesChange = () => false,
|
|
magicNotesEnabled = false,
|
|
remoteProjectsEnabled = false,
|
|
onConversationHtmlRenderingEnabledChange = () => {},
|
|
onMagicNotesEnabledChange = () => {},
|
|
onRemoteProjectsEnabledChange = () => {},
|
|
onMagicNotesShowIncompleteTodoCountChange = () => {},
|
|
onShortcutSettingsChanged = () => {},
|
|
onLeaveRequestReady = () => {}
|
|
}: SettingsPanelProps): React.JSX.Element | null {
|
|
const { i18n, t } = useTranslation('settings')
|
|
const {
|
|
preference: uiLocalePreference,
|
|
setPreference: setUiLocalePreference
|
|
} = useUiLocale()
|
|
const [settings, setSettings] = useState<RuntimeSettings>()
|
|
const [brandingDirty, setBrandingDirty] = useState(false)
|
|
const [provider, setProvider] =
|
|
useState<RuntimeSettingsInput['provider']>(
|
|
defaultRuntimeSettings.provider
|
|
)
|
|
const [modelProfiles, setModelProfiles] = useState<ModelProfileDraft[]>([])
|
|
const [selectedModelProfileId, setSelectedModelProfileId] =
|
|
useState('')
|
|
const [defaultModelProfileId, setDefaultModelProfileId] = useState('')
|
|
const [opencodeModelSource, setOpencodeModelSource] =
|
|
useState<RuntimeModelSource>({ kind: 'platform' })
|
|
const [continueModelSource, setContinueModelSource] =
|
|
useState<RuntimeModelSource>({ kind: 'platform' })
|
|
const [deepseekHarnessModelSource, setDeepseekHarnessModelSource] =
|
|
useState<RuntimeModelSource>({ kind: 'platform' })
|
|
const [opencodeBaseUrl, setOpencodeBaseUrl] = useState<string>(
|
|
defaultRuntimeSettings.opencodeBaseUrl
|
|
)
|
|
const [opencodeBinaryPath, setOpencodeBinaryPath] = useState<string>(
|
|
defaultRuntimeSettings.opencodeBinaryPath
|
|
)
|
|
const [opencodeConfigPath, setOpencodeConfigPath] = useState<string>(
|
|
defaultRuntimeSettings.opencodeConfigPath
|
|
)
|
|
const [continueBinaryPath, setContinueBinaryPath] = useState<string>(
|
|
defaultRuntimeSettings.continueBinaryPath
|
|
)
|
|
const [continueConfigPath, setContinueConfigPath] = useState<string>(
|
|
defaultRuntimeSettings.continueConfigPath
|
|
)
|
|
const [continueMode, setContinueMode] =
|
|
useState<RuntimeSettingsInput['continueMode']>(
|
|
defaultRuntimeSettings.continueMode
|
|
)
|
|
const [knowledgeEmbeddingEnabled, setKnowledgeEmbeddingEnabled] =
|
|
useState<boolean>(defaultRuntimeSettings.knowledgeEmbeddingEnabled)
|
|
const [knowledgeEmbeddingBaseUrl, setKnowledgeEmbeddingBaseUrl] =
|
|
useState<string>(defaultRuntimeSettings.knowledgeEmbeddingBaseUrl)
|
|
const [knowledgeEmbeddingModel, setKnowledgeEmbeddingModel] =
|
|
useState<string>(defaultRuntimeSettings.knowledgeEmbeddingModel)
|
|
const [knowledgeEmbeddingApiKey, setKnowledgeEmbeddingApiKey] =
|
|
useState('')
|
|
const [
|
|
clearKnowledgeEmbeddingApiKey,
|
|
setClearKnowledgeEmbeddingApiKey
|
|
] = useState(false)
|
|
const [embeddingConnections, setEmbeddingConnections] = useState<
|
|
EmbeddingConnectionDraft[]
|
|
>([])
|
|
const [
|
|
activeEmbeddingConnectionId,
|
|
setActiveEmbeddingConnectionId
|
|
] = useState(builtinEmbeddingConnectionId)
|
|
const [editingEmbeddingConnectionId, setEditingEmbeddingConnectionId] =
|
|
useState<string>()
|
|
const [knowledgeRerankEnabled, setKnowledgeRerankEnabled] =
|
|
useState<boolean>(defaultRuntimeSettings.knowledgeRerankEnabled)
|
|
const [knowledgeRerankEndpoint, setKnowledgeRerankEndpoint] =
|
|
useState<string>(defaultRuntimeSettings.knowledgeRerankEndpoint)
|
|
const [knowledgeRerankModel, setKnowledgeRerankModel] =
|
|
useState<string>(defaultRuntimeSettings.knowledgeRerankModel)
|
|
const [knowledgeRerankApiKey, setKnowledgeRerankApiKey] =
|
|
useState('')
|
|
const [clearKnowledgeRerankApiKey, setClearKnowledgeRerankApiKey] =
|
|
useState(false)
|
|
const [workspacePath, setWorkspacePath] = useState<string>(
|
|
defaultRuntimeSettings.workspacePath
|
|
)
|
|
const [toolApproval, setToolApproval] =
|
|
useState<RuntimeSettingsInput['toolApproval']>(
|
|
defaultRuntimeSettings.toolApproval
|
|
)
|
|
const [
|
|
subagentSmartRoutingEnabled,
|
|
setSubagentSmartRoutingEnabled
|
|
] = useState(false)
|
|
const [contextCompression, setContextCompression] =
|
|
useState<ContextCompressionSettings>(
|
|
defaultContextCompressionSettings
|
|
)
|
|
const [
|
|
contextCompressionTokenInput,
|
|
setContextCompressionTokenInput
|
|
] = useState<ContextCompressionTokenDrafts>(() =>
|
|
contextCompressionTokenDrafts(
|
|
defaultContextCompressionSettings
|
|
)
|
|
)
|
|
const modelProfileDisplayName = (
|
|
profile: Pick<ModelProfileDraft, 'id' | 'name'>
|
|
): string =>
|
|
profile.id === builtInDefaultModelProfileId &&
|
|
profile.name === '默认模型'
|
|
? t('model.profile.seededDefaultName')
|
|
: profile.name
|
|
const [saving, setSaving] = useState(false)
|
|
const [testing, setTesting] = useState(false)
|
|
const [embeddingSnapshot, setEmbeddingSnapshot] =
|
|
useState<EmbeddingSettingsSnapshot>()
|
|
const [embeddingModels, setEmbeddingModels] =
|
|
useState<EmbeddingModelSnapshot>()
|
|
const [embeddingDiagnostic, setEmbeddingDiagnostic] =
|
|
useState<EmbeddingDiagnosticResult>()
|
|
const [embeddingDiagnosticRunning, setEmbeddingDiagnosticRunning] =
|
|
useState(false)
|
|
const [embeddingModelActionRunning, setEmbeddingModelActionRunning] =
|
|
useState(false)
|
|
const [embeddingModelPendingOperation, setEmbeddingModelPendingOperation] =
|
|
useState<{
|
|
modelId: string
|
|
kind: EmbeddingModelSnapshot['operations'][number]['kind']
|
|
}>()
|
|
const [error, setError] = useState<string>()
|
|
const [confirmingClear, setConfirmingClear] = useState(false)
|
|
const [clearingLocalData, setClearingLocalData] = useState(false)
|
|
const [detection, setDetection] = useState<AgentRuntimeDetection>()
|
|
const [detecting, setDetecting] = useState(false)
|
|
const [selectedTab, setActiveTab] =
|
|
useState<SettingsCategoryId>(
|
|
initialCategory === 'ssh-hosts' && !remoteProjectsEnabled
|
|
? 'platform-features'
|
|
: initialCategory ?? 'runtime'
|
|
)
|
|
const activeTab =
|
|
selectedTab === 'ssh-hosts' && !remoteProjectsEnabled
|
|
? 'platform-features'
|
|
: selectedTab
|
|
const [modelType, setModelType] = useState<ModelType>('llm')
|
|
const [speechModelDraftId, setSpeechModelDraftId] = useState<
|
|
string | null | undefined
|
|
>()
|
|
const [
|
|
persistedSpeechModelId,
|
|
setPersistedSpeechModelId
|
|
] = useState<string | null | undefined>()
|
|
const [speechModelSelectionDirty, setSpeechModelSelectionDirty] =
|
|
useState(false)
|
|
const [agentRuntimeType, setAgentRuntimeType] =
|
|
useState<AgentRuntimeType>('opencode')
|
|
const [runtimeCustomizationDirty, setRuntimeCustomizationDirty] =
|
|
useState(false)
|
|
const [platformFeaturesDirty, setPlatformFeaturesDirty] =
|
|
useState(false)
|
|
const [documentParsingDirty, setDocumentParsingDirty] =
|
|
useState(false)
|
|
const [sshHostsDirty, setSshHostsDirty] = useState(false)
|
|
const [pendingLeave, setPendingLeave] = useState<
|
|
| { kind: 'close' }
|
|
| { kind: 'navigate'; category: SettingsCategoryId }
|
|
| { kind: 'external'; proceed: () => void }
|
|
>()
|
|
const runtimeCustomizationRef =
|
|
useRef<RuntimeCustomizationSectionHandle>(null)
|
|
const keepEditingRef = useRef<HTMLButtonElement>(null)
|
|
const leaveTriggerRef = useRef<HTMLElement | undefined>(undefined)
|
|
const handleRuntimeCustomizationDirtyChange = useCallback(
|
|
(dirty: boolean): void => {
|
|
setRuntimeCustomizationDirty(dirty)
|
|
if (!dirty) {
|
|
setError((current) =>
|
|
current === t('runtime.customization.unsavedClose')
|
|
? undefined
|
|
: current
|
|
)
|
|
}
|
|
},
|
|
[t]
|
|
)
|
|
const settingsBodyRef = useRef<HTMLDivElement>(null)
|
|
const hydrateSettings = useCallback(
|
|
(
|
|
value: RuntimeSettings,
|
|
preserveSelectedProfile = false
|
|
): void => {
|
|
hydrateRuntimeSettings(
|
|
value,
|
|
{
|
|
settings: setSettings,
|
|
provider: setProvider,
|
|
modelProfiles: setModelProfiles,
|
|
selectedModelProfileId: setSelectedModelProfileId,
|
|
defaultModelProfileId: setDefaultModelProfileId,
|
|
opencodeModelSource: setOpencodeModelSource,
|
|
continueModelSource: setContinueModelSource,
|
|
deepseekHarnessModelSource: setDeepseekHarnessModelSource,
|
|
opencodeBaseUrl: setOpencodeBaseUrl,
|
|
opencodeBinaryPath: setOpencodeBinaryPath,
|
|
opencodeConfigPath: setOpencodeConfigPath,
|
|
continueBinaryPath: setContinueBinaryPath,
|
|
continueConfigPath: setContinueConfigPath,
|
|
continueMode: setContinueMode,
|
|
knowledgeEmbeddingEnabled: setKnowledgeEmbeddingEnabled,
|
|
knowledgeEmbeddingBaseUrl: setKnowledgeEmbeddingBaseUrl,
|
|
knowledgeEmbeddingModel: setKnowledgeEmbeddingModel,
|
|
knowledgeEmbeddingApiKey: setKnowledgeEmbeddingApiKey,
|
|
clearKnowledgeEmbeddingApiKey: setClearKnowledgeEmbeddingApiKey,
|
|
embeddingConnections: setEmbeddingConnections,
|
|
activeEmbeddingConnectionId:
|
|
setActiveEmbeddingConnectionId,
|
|
knowledgeRerankEnabled: setKnowledgeRerankEnabled,
|
|
knowledgeRerankEndpoint: setKnowledgeRerankEndpoint,
|
|
knowledgeRerankModel: setKnowledgeRerankModel,
|
|
knowledgeRerankApiKey: setKnowledgeRerankApiKey,
|
|
clearKnowledgeRerankApiKey: setClearKnowledgeRerankApiKey,
|
|
workspacePath: setWorkspacePath,
|
|
toolApproval: setToolApproval,
|
|
subagentSmartRoutingEnabled: setSubagentSmartRoutingEnabled,
|
|
contextCompression: setContextCompression
|
|
},
|
|
preserveSelectedProfile
|
|
)
|
|
setContextCompressionTokenInput(
|
|
contextCompressionTokenDrafts(
|
|
value.contextCompression ??
|
|
defaultContextCompressionSettings
|
|
)
|
|
)
|
|
},
|
|
[]
|
|
)
|
|
const configurationTab =
|
|
activeTab === 'model' ||
|
|
activeTab === 'context-control' ||
|
|
activeTab === 'runtime' ||
|
|
activeTab === 'security' ||
|
|
activeTab === 'roles'
|
|
const categoryRendersOwnHeader =
|
|
activeTab === 'platform-features' ||
|
|
activeTab === 'document-parsing' ||
|
|
activeTab === 'ssh-hosts' ||
|
|
activeTab === 'channels' ||
|
|
activeTab === 'capabilities' ||
|
|
activeTab === 'about'
|
|
|
|
const savedConfiguredSettings = settings
|
|
? configuredRuntimeSettings(settings)
|
|
: undefined
|
|
const runtimeDraftDirty =
|
|
settings !== undefined &&
|
|
JSON.stringify({
|
|
provider,
|
|
modelProfiles,
|
|
defaultModelProfileId,
|
|
opencodeModelSource,
|
|
continueModelSource,
|
|
deepseekHarnessModelSource,
|
|
opencodeBaseUrl,
|
|
opencodeBinaryPath,
|
|
opencodeConfigPath,
|
|
continueBinaryPath,
|
|
continueConfigPath,
|
|
continueMode,
|
|
knowledgeEmbeddingEnabled,
|
|
knowledgeEmbeddingBaseUrl,
|
|
knowledgeEmbeddingModel,
|
|
knowledgeEmbeddingApiKey,
|
|
clearKnowledgeEmbeddingApiKey,
|
|
embeddingConnections,
|
|
activeEmbeddingConnectionId,
|
|
knowledgeRerankEnabled,
|
|
knowledgeRerankEndpoint,
|
|
knowledgeRerankModel,
|
|
knowledgeRerankApiKey,
|
|
clearKnowledgeRerankApiKey,
|
|
workspacePath,
|
|
toolApproval,
|
|
subagentSmartRoutingEnabled,
|
|
contextCompression,
|
|
contextCompressionTokenInput
|
|
}) !==
|
|
JSON.stringify({
|
|
provider: settings.provider,
|
|
modelProfiles: toModelProfileDrafts(settings),
|
|
defaultModelProfileId: settings.defaultModelProfileId,
|
|
opencodeModelSource:
|
|
savedConfiguredSettings?.opencodeModelSource,
|
|
continueModelSource:
|
|
savedConfiguredSettings?.continueModelSource,
|
|
deepseekHarnessModelSource:
|
|
savedConfiguredSettings?.deepseekHarnessModelSource ?? {
|
|
kind: 'platform'
|
|
},
|
|
opencodeBaseUrl: savedConfiguredSettings?.opencodeBaseUrl,
|
|
opencodeBinaryPath:
|
|
savedConfiguredSettings?.opencodeBinaryPath,
|
|
opencodeConfigPath:
|
|
savedConfiguredSettings?.opencodeConfigPath,
|
|
continueBinaryPath:
|
|
savedConfiguredSettings?.continueBinaryPath,
|
|
continueConfigPath:
|
|
savedConfiguredSettings?.continueConfigPath,
|
|
continueMode: settings.continueMode,
|
|
knowledgeEmbeddingEnabled:
|
|
settings.knowledgeEmbeddingEnabled,
|
|
knowledgeEmbeddingBaseUrl:
|
|
settings.knowledgeEmbeddingBaseUrl,
|
|
knowledgeEmbeddingModel: settings.knowledgeEmbeddingModel,
|
|
knowledgeEmbeddingApiKey: '',
|
|
clearKnowledgeEmbeddingApiKey: false,
|
|
embeddingConnections: toEmbeddingConnectionDrafts(settings),
|
|
activeEmbeddingConnectionId:
|
|
settings.activeEmbeddingConnectionId ??
|
|
builtinEmbeddingConnectionId,
|
|
knowledgeRerankEnabled:
|
|
settings.knowledgeRerankEnabled ??
|
|
defaultRuntimeSettings.knowledgeRerankEnabled,
|
|
knowledgeRerankEndpoint:
|
|
settings.knowledgeRerankEndpoint ??
|
|
defaultRuntimeSettings.knowledgeRerankEndpoint,
|
|
knowledgeRerankModel:
|
|
settings.knowledgeRerankModel ??
|
|
defaultRuntimeSettings.knowledgeRerankModel,
|
|
knowledgeRerankApiKey: '',
|
|
clearKnowledgeRerankApiKey: false,
|
|
workspacePath: savedConfiguredSettings?.workspacePath,
|
|
toolApproval:
|
|
settings.toolApproval === 'policy' ? 'policy' : 'always',
|
|
subagentSmartRoutingEnabled:
|
|
settings.subagentSmartRoutingEnabled,
|
|
contextCompression:
|
|
settings.contextCompression ??
|
|
defaultContextCompressionSettings,
|
|
contextCompressionTokenInput: contextCompressionTokenDrafts(
|
|
settings.contextCompression ??
|
|
defaultContextCompressionSettings
|
|
)
|
|
})
|
|
const hasUnsavedDrafts =
|
|
runtimeDraftDirty ||
|
|
speechModelSelectionDirty ||
|
|
runtimeCustomizationDirty ||
|
|
brandingDirty ||
|
|
platformFeaturesDirty ||
|
|
documentParsingDirty ||
|
|
sshHostsDirty
|
|
const navigationWouldLoseDraft =
|
|
runtimeCustomizationDirty ||
|
|
brandingDirty ||
|
|
platformFeaturesDirty ||
|
|
documentParsingDirty ||
|
|
sshHostsDirty
|
|
const unsavedCloseMessage =
|
|
runtimeCustomizationDirty &&
|
|
!runtimeDraftDirty &&
|
|
!speechModelSelectionDirty &&
|
|
!brandingDirty &&
|
|
!platformFeaturesDirty &&
|
|
!documentParsingDirty &&
|
|
!sshHostsDirty
|
|
? t('runtime.customization.unsavedClose')
|
|
: t('unsaved.close')
|
|
|
|
useEffect(() => {
|
|
if (!open || !hasUnsavedDrafts) {
|
|
return
|
|
}
|
|
const preventUnload = (event: BeforeUnloadEvent): void => {
|
|
event.preventDefault()
|
|
event.returnValue = ''
|
|
}
|
|
window.addEventListener('beforeunload', preventUnload)
|
|
return () =>
|
|
window.removeEventListener('beforeunload', preventUnload)
|
|
}, [hasUnsavedDrafts, open])
|
|
|
|
const requestTabChange = (
|
|
category: SettingsCategoryId,
|
|
trigger?: HTMLElement
|
|
): boolean => {
|
|
if (category === activeTab) {
|
|
return true
|
|
}
|
|
if (navigationWouldLoseDraft) {
|
|
leaveTriggerRef.current =
|
|
trigger ??
|
|
(document.activeElement instanceof HTMLElement
|
|
? document.activeElement
|
|
: undefined)
|
|
setPendingLeave({ kind: 'navigate', category })
|
|
return false
|
|
}
|
|
setPendingLeave(undefined)
|
|
setError(undefined)
|
|
setActiveTab(category)
|
|
return true
|
|
}
|
|
|
|
const visibleSettingsCategories = getSettingsCategoryList(
|
|
remoteProjectsEnabled
|
|
)
|
|
const settingsTabs = visibleSettingsCategories.map(({ id }) => id)
|
|
|
|
const handleTabKeyDown = (
|
|
event: React.KeyboardEvent<HTMLButtonElement>,
|
|
tab: SettingsCategoryId
|
|
): void => {
|
|
const currentIndex = settingsTabs.indexOf(tab)
|
|
let nextIndex: number | undefined
|
|
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
|
nextIndex = (currentIndex + 1) % settingsTabs.length
|
|
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
|
nextIndex =
|
|
(currentIndex - 1 + settingsTabs.length) %
|
|
settingsTabs.length
|
|
} else if (event.key === 'Home') {
|
|
nextIndex = 0
|
|
} else if (event.key === 'End') {
|
|
nextIndex = settingsTabs.length - 1
|
|
}
|
|
if (nextIndex === undefined) {
|
|
return
|
|
}
|
|
event.preventDefault()
|
|
const nextTab = settingsTabs[nextIndex]!
|
|
if (!requestTabChange(nextTab, event.currentTarget)) {
|
|
return
|
|
}
|
|
event.currentTarget.parentElement
|
|
?.querySelector<HTMLButtonElement>(
|
|
`#settings-tab-${nextTab}`
|
|
)
|
|
?.focus()
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
return
|
|
}
|
|
void window.goodbuddy.settings
|
|
.getRuntime()
|
|
.then((value) => {
|
|
setError(undefined)
|
|
setConfirmingClear(false)
|
|
setClearingLocalData(false)
|
|
setEmbeddingModelPendingOperation(undefined)
|
|
setModelType('llm')
|
|
setSpeechModelDraftId(undefined)
|
|
setPersistedSpeechModelId(undefined)
|
|
setSpeechModelSelectionDirty(false)
|
|
setPlatformFeaturesDirty(false)
|
|
setDocumentParsingDirty(false)
|
|
setSshHostsDirty(false)
|
|
setPendingLeave(undefined)
|
|
setAgentRuntimeType('opencode')
|
|
hydrateSettings(value)
|
|
})
|
|
.catch((reason: unknown) => {
|
|
setError(
|
|
settingsErrorMessage(
|
|
reason,
|
|
i18n.t('errors.readSettings', { ns: 'settings' })
|
|
)
|
|
)
|
|
})
|
|
void window.goodbuddy.settings
|
|
.detectAgentRuntimes()
|
|
.then(setDetection)
|
|
.catch((reason: unknown) => {
|
|
setError(
|
|
settingsErrorMessage(
|
|
reason,
|
|
i18n.t('errors.detectRuntimes', { ns: 'settings' })
|
|
)
|
|
)
|
|
})
|
|
}, [hydrateSettings, i18n, open])
|
|
|
|
useEffect(() => {
|
|
if (open && settingsBodyRef.current) {
|
|
settingsBodyRef.current.scrollTop = 0
|
|
}
|
|
}, [activeTab, open])
|
|
|
|
useEffect(() => {
|
|
const embeddings = window.goodbuddy.embeddings
|
|
if (!open || !embeddings) {
|
|
return
|
|
}
|
|
let active = true
|
|
void embeddings
|
|
.getSnapshot()
|
|
.then((snapshot) => {
|
|
if (active) {
|
|
setEmbeddingDiagnostic(undefined)
|
|
setEmbeddingSnapshot(snapshot)
|
|
setEmbeddingModels(snapshot.models)
|
|
}
|
|
})
|
|
.catch((reason: unknown) => {
|
|
if (active) {
|
|
setError(
|
|
settingsErrorMessage(
|
|
reason,
|
|
i18n.t('errors.readEmbeddingStatus', {
|
|
ns: 'settings'
|
|
})
|
|
)
|
|
)
|
|
}
|
|
})
|
|
return () => {
|
|
active = false
|
|
}
|
|
}, [i18n, open])
|
|
|
|
const refreshEmbeddingModelProgress =
|
|
useCallback(async (): Promise<void> => {
|
|
const embeddings = window.goodbuddy.embeddings
|
|
if (!embeddings) {
|
|
return
|
|
}
|
|
const progress = await embeddings.getModelProgress()
|
|
setEmbeddingModels((current) =>
|
|
current
|
|
? { ...current, operations: progress.operations }
|
|
: current
|
|
)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!open ||
|
|
(!embeddingModelPendingOperation &&
|
|
!embeddingModels?.operations.length)
|
|
) {
|
|
return
|
|
}
|
|
let active = true
|
|
let timer: number | undefined
|
|
const poll = async (): Promise<void> => {
|
|
try {
|
|
await refreshEmbeddingModelProgress()
|
|
} catch {
|
|
// The final operation result reports actionable errors.
|
|
} finally {
|
|
if (active) {
|
|
timer = window.setTimeout(() => void poll(), 300)
|
|
}
|
|
}
|
|
}
|
|
timer = window.setTimeout(() => void poll(), 300)
|
|
return () => {
|
|
active = false
|
|
if (timer !== undefined) {
|
|
window.clearTimeout(timer)
|
|
}
|
|
}
|
|
}, [
|
|
embeddingModelPendingOperation,
|
|
embeddingModels?.operations.length,
|
|
open,
|
|
refreshEmbeddingModelProgress
|
|
])
|
|
|
|
const normalizedContextCompression =
|
|
normalizeContextCompressionTokenDrafts(
|
|
contextCompression,
|
|
contextCompressionTokenInput
|
|
)
|
|
const commitContextCompressionTokenInput =
|
|
(): ContextCompressionSettings => {
|
|
setContextCompression(normalizedContextCompression)
|
|
setContextCompressionTokenInput(
|
|
contextCompressionTokenDrafts(
|
|
normalizedContextCompression
|
|
)
|
|
)
|
|
return normalizedContextCompression
|
|
}
|
|
|
|
const close = (trigger?: HTMLElement): void => {
|
|
if (hasUnsavedDrafts) {
|
|
leaveTriggerRef.current =
|
|
trigger ??
|
|
(document.activeElement instanceof HTMLElement
|
|
? document.activeElement
|
|
: undefined)
|
|
setPendingLeave({ kind: 'close' })
|
|
return
|
|
}
|
|
closeImmediately()
|
|
}
|
|
|
|
const resetSensitiveDrafts = (): void => {
|
|
setModelProfiles((profiles) =>
|
|
profiles.map((profile) => ({
|
|
...profile,
|
|
apiKey: '',
|
|
clearApiKey: false
|
|
}))
|
|
)
|
|
setKnowledgeEmbeddingApiKey('')
|
|
setClearKnowledgeEmbeddingApiKey(false)
|
|
setEmbeddingConnections((connections) =>
|
|
connections.map((connection) => ({
|
|
...connection,
|
|
apiKey: '',
|
|
clearApiKey: false
|
|
}))
|
|
)
|
|
setEditingEmbeddingConnectionId(undefined)
|
|
setKnowledgeRerankApiKey('')
|
|
setClearKnowledgeRerankApiKey(false)
|
|
setSpeechModelDraftId(undefined)
|
|
setPersistedSpeechModelId(undefined)
|
|
setSpeechModelSelectionDirty(false)
|
|
setBrandingDirty(false)
|
|
setPlatformFeaturesDirty(false)
|
|
setDocumentParsingDirty(false)
|
|
setSshHostsDirty(false)
|
|
setPendingLeave(undefined)
|
|
setError(undefined)
|
|
}
|
|
|
|
const closeImmediately = (): void => {
|
|
resetSensitiveDrafts()
|
|
onClose()
|
|
}
|
|
|
|
const leaveSettings = (proceed: () => void): void => {
|
|
resetSensitiveDrafts()
|
|
proceed()
|
|
}
|
|
|
|
const discardAndLeave = (): void => {
|
|
if (settings) {
|
|
hydrateSettings(settings)
|
|
}
|
|
setSpeechModelDraftId(persistedSpeechModelId)
|
|
setSpeechModelSelectionDirty(false)
|
|
runtimeCustomizationRef.current?.discard()
|
|
setRuntimeCustomizationDirty(false)
|
|
setBrandingDirty(false)
|
|
setPlatformFeaturesDirty(false)
|
|
setDocumentParsingDirty(false)
|
|
setSshHostsDirty(false)
|
|
const leave = pendingLeave
|
|
setPendingLeave(undefined)
|
|
setError(undefined)
|
|
if (leave?.kind === 'navigate') {
|
|
setActiveTab(leave.category)
|
|
requestAnimationFrame(() =>
|
|
document
|
|
.getElementById(`settings-tab-${leave.category}`)
|
|
?.focus()
|
|
)
|
|
return
|
|
}
|
|
if (leave?.kind === 'external') {
|
|
setActiveTab('runtime')
|
|
leaveSettings(leave.proceed)
|
|
return
|
|
}
|
|
if (leave?.kind === 'close') {
|
|
closeImmediately()
|
|
}
|
|
}
|
|
|
|
const keepEditing = (): void => {
|
|
const trigger = leaveTriggerRef.current
|
|
setPendingLeave(undefined)
|
|
setError(undefined)
|
|
requestAnimationFrame(() => {
|
|
if (trigger?.isConnected) {
|
|
trigger.focus()
|
|
}
|
|
})
|
|
}
|
|
|
|
useLayoutEffect(() => {
|
|
if (!pendingLeave) {
|
|
return
|
|
}
|
|
if (settingsBodyRef.current) {
|
|
settingsBodyRef.current.scrollTop = 0
|
|
}
|
|
keepEditingRef.current?.focus()
|
|
}, [pendingLeave])
|
|
|
|
useLayoutEffect(() => {
|
|
const requestLeave: SettingsLeaveRequester = (proceed) => {
|
|
if (hasUnsavedDrafts) {
|
|
leaveTriggerRef.current =
|
|
document.activeElement instanceof HTMLElement
|
|
? document.activeElement
|
|
: undefined
|
|
setPendingLeave({ kind: 'external', proceed })
|
|
return
|
|
}
|
|
resetSensitiveDrafts()
|
|
proceed()
|
|
}
|
|
onLeaveRequestReady(requestLeave)
|
|
return () => onLeaveRequestReady(undefined)
|
|
}, [hasUnsavedDrafts, onLeaveRequestReady])
|
|
|
|
if (!open) {
|
|
return null
|
|
}
|
|
|
|
const save = async (
|
|
notifySuccess = true
|
|
): Promise<RuntimeSettings | undefined> => {
|
|
setSaving(true)
|
|
setError(undefined)
|
|
try {
|
|
const contextCompressionInput =
|
|
commitContextCompressionTokenInput()
|
|
const defaultProfile =
|
|
modelProfiles.find(
|
|
(profile) => profile.id === defaultModelProfileId
|
|
) ?? modelProfiles[0]
|
|
if (!defaultProfile) {
|
|
throw new Error(t('errors.requireModelConnection'))
|
|
}
|
|
const configuredProfiles = new Map(
|
|
settings?.configured?.modelProfiles.map((profile) => [
|
|
profile.id,
|
|
profile
|
|
])
|
|
)
|
|
const profileInputs = modelProfiles.map((profile) => {
|
|
const configured = configuredProfiles.get(profile.id)
|
|
const environmentManaged =
|
|
profile.credentialSource === 'environment' &&
|
|
configured !== undefined
|
|
const requestHeaders = parseRequestHeadersDraft(
|
|
profile.requestHeadersText
|
|
)
|
|
if (!requestHeaders.success) {
|
|
throw new Error(
|
|
t('errors.invalidModelRequestHeaders', {
|
|
name: modelProfileDisplayName(profile)
|
|
})
|
|
)
|
|
}
|
|
const requestBody = parseRequestBodyDraft(
|
|
profile.requestBodyText
|
|
)
|
|
if (!requestBody.success) {
|
|
throw new Error(
|
|
t('errors.invalidModelRequestBody', {
|
|
name: modelProfileDisplayName(profile)
|
|
})
|
|
)
|
|
}
|
|
return {
|
|
id: profile.id,
|
|
name: profile.name,
|
|
baseUrl: environmentManaged
|
|
? configured.baseUrl
|
|
: profile.baseUrl,
|
|
modelName: environmentManaged
|
|
? configured.modelName
|
|
: profile.modelName,
|
|
protocol: profile.protocol,
|
|
authentication: profile.authentication,
|
|
supportsImageInput: profile.supportsImageInput,
|
|
contextWindowTokens: profile.contextWindowTokens,
|
|
maximumOutputTokens: profile.maximumOutputTokens,
|
|
imageGenerationQuality: profile.imageGenerationQuality,
|
|
requestHeaders: requestHeaders.data,
|
|
requestBody: requestBody.data,
|
|
apiKey: profile.clearApiKey
|
|
? ({ action: 'clear' } as const)
|
|
: profile.authentication === 'api-key' &&
|
|
profile.apiKey.trim()
|
|
? ({
|
|
action: 'replace',
|
|
value: profile.apiKey.trim()
|
|
} as const)
|
|
: ({ action: 'keep' } as const)
|
|
}
|
|
})
|
|
const defaultProfileInput =
|
|
profileInputs.find(
|
|
(profile) => profile.id === defaultProfile.id
|
|
) ?? profileInputs[0]!
|
|
const normalizedDeepseekHarnessModelSource =
|
|
deepseekHarnessModelSource.kind === 'profile' &&
|
|
deepseekHarnessModelSource.profileId === defaultProfile.id &&
|
|
defaultProfile.credentialSource === 'environment'
|
|
? { kind: 'platform' as const }
|
|
: deepseekHarnessModelSource
|
|
const value = await window.goodbuddy.settings.updateRuntime({
|
|
provider,
|
|
modelBaseUrl: defaultProfileInput.baseUrl,
|
|
modelName: defaultProfileInput.modelName,
|
|
modelProtocol: defaultProfileInput.protocol,
|
|
modelAuthentication: defaultProfileInput.authentication,
|
|
imageGenerationQuality:
|
|
defaultProfileInput.imageGenerationQuality,
|
|
opencodeBaseUrl,
|
|
opencodeEmbedded: !opencodeBaseUrl,
|
|
opencodeBinaryPath,
|
|
opencodeConfigPath,
|
|
continueBinaryPath,
|
|
continueConfigPath,
|
|
continueMode,
|
|
knowledgeEmbeddingEnabled,
|
|
knowledgeEmbeddingBaseUrl,
|
|
knowledgeEmbeddingModel,
|
|
knowledgeEmbeddingApiKey: clearKnowledgeEmbeddingApiKey
|
|
? { action: 'clear' }
|
|
: knowledgeEmbeddingApiKey.trim()
|
|
? {
|
|
action: 'replace',
|
|
value: knowledgeEmbeddingApiKey.trim()
|
|
}
|
|
: { action: 'keep' },
|
|
embeddingConnections: [
|
|
{
|
|
id: builtinEmbeddingConnectionId,
|
|
name: 'GoodBuddy 内置向量模型',
|
|
kind: 'builtin'
|
|
},
|
|
...embeddingConnections.map((connection) => ({
|
|
id: connection.id,
|
|
name: connection.name,
|
|
kind: connection.kind,
|
|
baseUrl: connection.baseUrl,
|
|
modelName: connection.modelName,
|
|
authentication: connection.authentication,
|
|
apiKey: connection.clearApiKey
|
|
? ({ action: 'clear' } as const)
|
|
: connection.authentication === 'api-key' &&
|
|
connection.apiKey.trim()
|
|
? ({
|
|
action: 'replace',
|
|
value: connection.apiKey.trim()
|
|
} as const)
|
|
: ({ action: 'keep' } as const)
|
|
}))
|
|
],
|
|
activeEmbeddingConnectionId,
|
|
knowledgeRerankEnabled,
|
|
knowledgeRerankEndpoint,
|
|
knowledgeRerankModel,
|
|
knowledgeRerankApiKey: clearKnowledgeRerankApiKey
|
|
? { action: 'clear' }
|
|
: knowledgeRerankApiKey.trim()
|
|
? {
|
|
action: 'replace',
|
|
value: knowledgeRerankApiKey.trim()
|
|
}
|
|
: { action: 'keep' },
|
|
workspacePath,
|
|
apiKey: defaultProfileInput.apiKey,
|
|
modelProfiles: profileInputs,
|
|
defaultModelProfileId: defaultProfile.id,
|
|
opencodeModelSource,
|
|
continueModelSource,
|
|
deepseekHarnessModelSource:
|
|
normalizedDeepseekHarnessModelSource,
|
|
contextCompression: contextCompressionInput,
|
|
toolApproval,
|
|
subagentSmartRoutingEnabled
|
|
})
|
|
let selectedSpeechModelId = speechModelDraftId
|
|
if (speechModelSelectionDirty) {
|
|
const speechModels = window.goodbuddy.speechModels
|
|
if (!speechModels) {
|
|
throw new Error(t('errors.speechModelsUnavailable'))
|
|
}
|
|
const speechSnapshot = await speechModels.select(
|
|
speechModelDraftId ?? null
|
|
)
|
|
selectedSpeechModelId = speechSnapshot.selectedModelId
|
|
}
|
|
hydrateSettings(value, true)
|
|
setPendingLeave(undefined)
|
|
if (speechModelSelectionDirty) {
|
|
setSpeechModelDraftId(selectedSpeechModelId)
|
|
setPersistedSpeechModelId(selectedSpeechModelId)
|
|
setSpeechModelSelectionDirty(false)
|
|
}
|
|
const embeddings = window.goodbuddy.embeddings
|
|
if (embeddings) {
|
|
try {
|
|
const snapshot = await embeddings.getSnapshot()
|
|
setEmbeddingSnapshot(snapshot)
|
|
setEmbeddingModels(snapshot.models)
|
|
} catch (reason) {
|
|
setError(
|
|
settingsErrorMessage(
|
|
reason,
|
|
t('errors.refreshEmbeddingAfterSave')
|
|
)
|
|
)
|
|
}
|
|
}
|
|
onSaved(value)
|
|
if (activeTab === 'runtime') {
|
|
const customizationSaved =
|
|
(await runtimeCustomizationRef.current?.save()) ?? true
|
|
if (!customizationSaved) {
|
|
return undefined
|
|
}
|
|
}
|
|
if (notifySuccess) {
|
|
onNotify({
|
|
tone: 'success',
|
|
message: t('notifications.settingsSaved'),
|
|
dedupeKey: 'runtime-settings-saved'
|
|
})
|
|
}
|
|
return value
|
|
} catch (reason) {
|
|
setError(settingsErrorMessage(reason, t('errors.saveSettings')))
|
|
return undefined
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const testConnection = async (): Promise<void> => {
|
|
const testingModel = activeTab === 'model' && modelType === 'llm'
|
|
const profileId = selectedModelProfileId
|
|
setTesting(true)
|
|
const savedSettings = await save(false)
|
|
if (!savedSettings) {
|
|
setTesting(false)
|
|
return
|
|
}
|
|
try {
|
|
const runtimeSource =
|
|
agentRuntimeType === 'opencode'
|
|
? savedSettings.opencodeModelSource
|
|
: agentRuntimeType === 'continue'
|
|
? savedSettings.continueModelSource
|
|
: savedSettings.deepseekHarnessModelSource ?? {
|
|
kind: 'platform'
|
|
}
|
|
const runtimeSelection =
|
|
runtimeSource.kind === 'profile'
|
|
? {
|
|
provider: agentRuntimeType,
|
|
profileId: runtimeSource.profileId
|
|
}
|
|
: { provider: agentRuntimeType }
|
|
const status = testingModel
|
|
? await window.goodbuddy.settings.testModelConnection(profileId)
|
|
: await window.goodbuddy.settings.testRuntime(runtimeSelection)
|
|
if (!status.available) {
|
|
throw new Error(status.detail)
|
|
}
|
|
onNotify({
|
|
tone: 'success',
|
|
message: testingModel
|
|
? t('notifications.modelGenerationTestSucceeded', {
|
|
label: status.label
|
|
})
|
|
: t('notifications.connectionSucceeded', {
|
|
label: status.label
|
|
}),
|
|
dedupeKey: testingModel
|
|
? 'model-connection-tested'
|
|
: `runtime-connection-tested-${agentRuntimeType}`
|
|
})
|
|
} catch (reason) {
|
|
setError(
|
|
settingsErrorMessage(
|
|
reason,
|
|
testingModel
|
|
? t('errors.testModel')
|
|
: t('errors.testRuntime')
|
|
)
|
|
)
|
|
} finally {
|
|
setTesting(false)
|
|
}
|
|
}
|
|
|
|
const runEmbeddingDiagnostic = async (
|
|
connectionId: string
|
|
): Promise<void> => {
|
|
const embeddings = window.goodbuddy.embeddings
|
|
if (!embeddings) {
|
|
setError(t('errors.embeddingDiagnosticUnavailable'))
|
|
return
|
|
}
|
|
setEmbeddingDiagnosticRunning(true)
|
|
setEmbeddingDiagnostic(undefined)
|
|
setError(undefined)
|
|
try {
|
|
if (!(await save(false))) {
|
|
return
|
|
}
|
|
const diagnostic = await embeddings.diagnose(connectionId)
|
|
setEmbeddingDiagnostic(diagnostic)
|
|
const snapshot = await embeddings.getSnapshot()
|
|
setEmbeddingSnapshot(snapshot)
|
|
setEmbeddingModels(snapshot.models)
|
|
} catch (reason) {
|
|
setError(settingsErrorMessage(reason, t('errors.testEmbedding')))
|
|
} finally {
|
|
setEmbeddingDiagnosticRunning(false)
|
|
}
|
|
}
|
|
|
|
const runEmbeddingModelOperation = async (
|
|
modelId: string,
|
|
kind:
|
|
| EmbeddingModelSnapshot['operations'][number]['kind']
|
|
| undefined,
|
|
operation: (
|
|
api: NonNullable<typeof window.goodbuddy.embeddings>
|
|
) => Promise<EmbeddingModelSnapshot | undefined>,
|
|
successMessage: string
|
|
): Promise<void> => {
|
|
const embeddings = window.goodbuddy.embeddings
|
|
if (!embeddings) {
|
|
setError(t('errors.embeddingDiagnosticUnavailable'))
|
|
return
|
|
}
|
|
setEmbeddingModelActionRunning(true)
|
|
setEmbeddingModelPendingOperation(
|
|
kind ? { modelId, kind } : undefined
|
|
)
|
|
setError(undefined)
|
|
let reconcileSnapshot = false
|
|
try {
|
|
const models = await operation(embeddings)
|
|
if (models) {
|
|
setEmbeddingModels(models)
|
|
setEmbeddingSnapshot((current) =>
|
|
current ? { ...current, models } : current
|
|
)
|
|
onNotify({
|
|
tone: 'success',
|
|
message: successMessage,
|
|
dedupeKey: `embedding-model-${modelId}`
|
|
})
|
|
} else {
|
|
reconcileSnapshot = true
|
|
}
|
|
} catch (reason) {
|
|
reconcileSnapshot = true
|
|
setError(
|
|
settingsErrorMessage(reason, t('errors.manageEmbeddingModel'))
|
|
)
|
|
} finally {
|
|
setEmbeddingModelActionRunning(false)
|
|
setEmbeddingModelPendingOperation(undefined)
|
|
if (reconcileSnapshot) {
|
|
void embeddings.getSnapshot().then(
|
|
(snapshot) => {
|
|
setEmbeddingSnapshot(snapshot)
|
|
setEmbeddingModels(snapshot.models)
|
|
},
|
|
() => undefined
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
const cancelEmbeddingModelOperation = async (
|
|
modelId: string
|
|
): Promise<void> => {
|
|
const embeddings = window.goodbuddy.embeddings
|
|
if (!embeddings) {
|
|
setError(t('errors.embeddingDiagnosticUnavailable'))
|
|
return
|
|
}
|
|
setError(undefined)
|
|
try {
|
|
await embeddings.cancelModelOperation(modelId)
|
|
const snapshot = await embeddings.getSnapshot()
|
|
setEmbeddingSnapshot(snapshot)
|
|
setEmbeddingModels(snapshot.models)
|
|
} catch (reason) {
|
|
setError(
|
|
settingsErrorMessage(reason, t('errors.manageEmbeddingModel'))
|
|
)
|
|
}
|
|
}
|
|
|
|
const selectRuntimeFile = async (
|
|
kind: RuntimeFileSelectionKind,
|
|
setValue: (value: string) => void
|
|
): Promise<void> => {
|
|
try {
|
|
const selected =
|
|
await window.goodbuddy.settings.selectRuntimeFile(kind)
|
|
if (selected) {
|
|
setValue(selected)
|
|
}
|
|
} catch (reason) {
|
|
setError(settingsErrorMessage(reason, t('errors.selectFile')))
|
|
}
|
|
}
|
|
|
|
const openRuntimeConfig = async (
|
|
runtime: RuntimeConfigActionInput['runtime'],
|
|
action: RuntimeConfigActionInput['action']
|
|
): Promise<void> => {
|
|
try {
|
|
await window.goodbuddy.settings.openRuntimeConfig({
|
|
runtime,
|
|
action
|
|
})
|
|
} catch (reason) {
|
|
setError(
|
|
settingsErrorMessage(reason, t('errors.openRuntimeConfig'))
|
|
)
|
|
}
|
|
}
|
|
|
|
const detectRuntimes = async (): Promise<void> => {
|
|
setDetecting(true)
|
|
setError(undefined)
|
|
try {
|
|
setDetection(
|
|
await window.goodbuddy.settings.detectAgentRuntimes()
|
|
)
|
|
} catch (reason) {
|
|
setError(settingsErrorMessage(reason, t('errors.detectRuntimes')))
|
|
} finally {
|
|
setDetecting(false)
|
|
}
|
|
}
|
|
|
|
const updateModelProfile = (
|
|
id: string,
|
|
update: Partial<ModelProfileDraft>
|
|
): void => {
|
|
setModelProfiles((profiles) =>
|
|
profiles.map((profile) =>
|
|
profile.id === id ? { ...profile, ...update } : profile
|
|
)
|
|
)
|
|
}
|
|
|
|
const addModelProfile = (): void => {
|
|
const id = crypto.randomUUID()
|
|
setModelProfiles((profiles) => [
|
|
...profiles,
|
|
{
|
|
id,
|
|
name: t('model.profile.generatedName', {
|
|
count: profiles.length + 1
|
|
}),
|
|
baseUrl: '',
|
|
modelName: '',
|
|
protocol: 'openai-chat-completions',
|
|
authentication: defaultRuntimeSettings.modelAuthentication,
|
|
supportsImageInput: defaultRuntimeSettings.supportsImageInput,
|
|
maximumOutputTokens: defaultAnthropicMaximumOutputTokens,
|
|
imageGenerationQuality:
|
|
defaultRuntimeSettings.imageGenerationQuality,
|
|
requestHeadersText: '{}',
|
|
requestBodyText: '{}',
|
|
apiKeyConfigured: false,
|
|
credentialSource: 'none',
|
|
apiKey: '',
|
|
clearApiKey: false
|
|
}
|
|
])
|
|
if (!defaultModelProfileId) {
|
|
setDefaultModelProfileId(id)
|
|
}
|
|
setSelectedModelProfileId(id)
|
|
}
|
|
|
|
const removeModelProfile = (id: string): void => {
|
|
if (modelProfiles.length <= 1) {
|
|
setError(t('errors.retainModelConnection'))
|
|
return
|
|
}
|
|
const removedIndex = modelProfiles.findIndex(
|
|
(profile) => profile.id === id
|
|
)
|
|
const remaining = modelProfiles.filter((profile) => profile.id !== id)
|
|
const compatibleFallback = findPreferredCompatibleModelProfile(
|
|
remaining,
|
|
defaultModelProfileId,
|
|
(profile) =>
|
|
isAgentRuntimeModelProtocol(profile.protocol)
|
|
)
|
|
const runtimeFallback: RuntimeModelSource = compatibleFallback
|
|
? { kind: 'profile', profileId: compatibleFallback.id }
|
|
: { kind: 'platform' }
|
|
setModelProfiles(remaining)
|
|
if (selectedModelProfileId === id) {
|
|
setSelectedModelProfileId(
|
|
remaining[Math.min(removedIndex, remaining.length - 1)]?.id ??
|
|
remaining[0]!.id
|
|
)
|
|
}
|
|
if (defaultModelProfileId === id) {
|
|
setDefaultModelProfileId(
|
|
compatibleFallback?.id ?? remaining[0]!.id
|
|
)
|
|
}
|
|
if (
|
|
opencodeModelSource.kind === 'profile' &&
|
|
opencodeModelSource.profileId === id
|
|
) {
|
|
setOpencodeModelSource(runtimeFallback)
|
|
}
|
|
if (
|
|
continueModelSource.kind === 'profile' &&
|
|
continueModelSource.profileId === id
|
|
) {
|
|
setContinueModelSource(runtimeFallback)
|
|
}
|
|
if (
|
|
deepseekHarnessModelSource.kind === 'profile' &&
|
|
deepseekHarnessModelSource.profileId === id
|
|
) {
|
|
const harnessFallback = findPreferredCompatibleModelProfile(
|
|
remaining,
|
|
defaultModelProfileId,
|
|
(profile) => profile.protocol === 'openai-chat-completions'
|
|
)
|
|
setDeepseekHarnessModelSource(
|
|
harnessFallback
|
|
? { kind: 'profile', profileId: harnessFallback.id }
|
|
: { kind: 'platform' }
|
|
)
|
|
}
|
|
if (
|
|
contextCompression.modelSource.kind === 'profile' &&
|
|
contextCompression.modelSource.profileId === id
|
|
) {
|
|
setContextCompression((current) => ({
|
|
...current,
|
|
modelSource: { kind: 'current' }
|
|
}))
|
|
}
|
|
}
|
|
|
|
const selectDefaultModelProfile = (
|
|
profile: ModelProfileDraft
|
|
): void => {
|
|
const previousDefaultProfileId = defaultModelProfileId
|
|
const compatibleProfile = findPreferredCompatibleModelProfile(
|
|
modelProfiles,
|
|
profile.id,
|
|
(candidate) =>
|
|
isAgentRuntimeModelProtocol(candidate.protocol)
|
|
)
|
|
const nextRuntimeSource: RuntimeModelSource = compatibleProfile
|
|
? { kind: 'profile', profileId: compatibleProfile.id }
|
|
: { kind: 'platform' }
|
|
setDefaultModelProfileId(profile.id)
|
|
if (
|
|
opencodeModelSource.kind === 'profile' &&
|
|
opencodeModelSource.profileId === previousDefaultProfileId
|
|
) {
|
|
setOpencodeModelSource(nextRuntimeSource)
|
|
}
|
|
if (
|
|
continueModelSource.kind === 'profile' &&
|
|
continueModelSource.profileId === previousDefaultProfileId
|
|
) {
|
|
setContinueModelSource(nextRuntimeSource)
|
|
}
|
|
if (
|
|
deepseekHarnessModelSource.kind === 'profile' &&
|
|
deepseekHarnessModelSource.profileId === previousDefaultProfileId &&
|
|
profile.protocol === 'openai-chat-completions'
|
|
) {
|
|
setDeepseekHarnessModelSource({
|
|
kind: 'profile',
|
|
profileId: profile.id
|
|
})
|
|
}
|
|
}
|
|
|
|
const parseModelSource = (value: string): RuntimeModelSource =>
|
|
value === 'platform'
|
|
? { kind: 'platform' }
|
|
: { kind: 'profile', profileId: value }
|
|
|
|
const isOpenCodeCompatible = (
|
|
profile: ModelProfileDraft
|
|
): boolean => isAgentRuntimeModelProtocol(profile.protocol)
|
|
|
|
const isContinueCompatible = (
|
|
profile: ModelProfileDraft
|
|
): boolean => isAgentRuntimeModelProtocol(profile.protocol)
|
|
|
|
const isDeepseekHarnessCompatible = (
|
|
profile: ModelProfileDraft
|
|
): boolean => isDeepSeekHarnessModelProfile(profile)
|
|
|
|
const selectedModelProfile =
|
|
modelProfiles.find(
|
|
(profile) => profile.id === selectedModelProfileId
|
|
) ?? modelProfiles[0]
|
|
const defaultTextModelProfile =
|
|
findPreferredCompatibleModelProfile(
|
|
modelProfiles,
|
|
defaultModelProfileId,
|
|
(profile) =>
|
|
isAgentRuntimeModelProtocol(profile.protocol)
|
|
)
|
|
const defaultDeepseekHarnessModelProfile =
|
|
findPreferredCompatibleModelProfile(
|
|
modelProfiles,
|
|
defaultModelProfileId,
|
|
isDeepseekHarnessCompatible
|
|
)
|
|
const activeRuntimeModelSource =
|
|
agentRuntimeType === 'opencode'
|
|
? opencodeModelSource
|
|
: agentRuntimeType === 'continue'
|
|
? continueModelSource
|
|
: deepseekHarnessModelSource
|
|
const activeRuntimeModelProfile =
|
|
activeRuntimeModelSource.kind === 'profile'
|
|
? modelProfiles.find(
|
|
(profile) =>
|
|
profile.id === activeRuntimeModelSource.profileId &&
|
|
(agentRuntimeType === 'deepseek-harness'
|
|
? isDeepseekHarnessCompatible(profile)
|
|
: isAgentRuntimeModelProtocol(profile.protocol))
|
|
)
|
|
: undefined
|
|
const savedRoleModelProfiles = (settings?.modelProfiles ?? [])
|
|
.filter((profile) =>
|
|
isAgentRuntimeModelProtocol(profile.protocol)
|
|
)
|
|
.map(({ id, name }) => ({
|
|
id,
|
|
name: modelProfileDisplayName({ id, name })
|
|
}))
|
|
const savedRoleDefaultModelProfileId =
|
|
savedRoleModelProfiles.some(
|
|
(profile) => profile.id === settings?.defaultModelProfileId
|
|
)
|
|
? settings?.defaultModelProfileId
|
|
: undefined
|
|
|
|
return (
|
|
<div
|
|
className={
|
|
presentation === 'page'
|
|
? 'settings-page'
|
|
: 'settings-backdrop'
|
|
}
|
|
role="presentation"
|
|
>
|
|
<section
|
|
aria-labelledby="settings-title"
|
|
aria-modal={presentation === 'modal' ? 'true' : undefined}
|
|
className="settings-panel"
|
|
role={presentation === 'modal' ? 'dialog' : 'region'}
|
|
>
|
|
<div className="settings-panel__header">
|
|
<div>
|
|
<PageHeader
|
|
actions={
|
|
<button
|
|
aria-label={t('center.close')}
|
|
className="icon-button"
|
|
onClick={(event) => close(event.currentTarget)}
|
|
type="button"
|
|
>
|
|
<X aria-hidden="true" size={19} />
|
|
</button>
|
|
}
|
|
description={t('center.description')}
|
|
headingId="settings-title"
|
|
title={t('center.title')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
aria-busy={saving || testing}
|
|
className="settings-panel__body"
|
|
inert={saving || testing ? true : undefined}
|
|
>
|
|
<nav
|
|
aria-label={t('center.categoriesAriaLabel')}
|
|
aria-orientation="vertical"
|
|
className="settings-tabs"
|
|
role="tablist"
|
|
>
|
|
{visibleSettingsCategories.map((category) => (
|
|
<button
|
|
aria-controls={`settings-panel-${category.id}`}
|
|
aria-label={t(
|
|
`categories.${category.translationKey}.label`
|
|
)}
|
|
aria-selected={activeTab === category.id}
|
|
id={`settings-tab-${category.id}`}
|
|
key={category.id}
|
|
onClick={(event) => {
|
|
requestTabChange(category.id, event.currentTarget)
|
|
}}
|
|
onKeyDown={(event) =>
|
|
handleTabKeyDown(event, category.id)
|
|
}
|
|
role="tab"
|
|
tabIndex={activeTab === category.id ? 0 : -1}
|
|
type="button"
|
|
>
|
|
<strong>
|
|
{t(`categories.${category.translationKey}.label`)}
|
|
</strong>
|
|
<small>
|
|
{t(
|
|
`categories.${category.translationKey}.navigationDescription`
|
|
)}
|
|
</small>
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
<div
|
|
aria-labelledby={`settings-tab-${activeTab}`}
|
|
className="settings-panel__content"
|
|
id={`settings-panel-${activeTab}`}
|
|
ref={settingsBodyRef}
|
|
role="tabpanel"
|
|
>
|
|
{pendingLeave && (
|
|
<div
|
|
className="settings-warning"
|
|
role="alert"
|
|
>
|
|
<p>
|
|
{pendingLeave.kind !== 'navigate'
|
|
? unsavedCloseMessage
|
|
: t('unsaved.navigation')}
|
|
</p>
|
|
<div className="update-settings__actions">
|
|
<button
|
|
className="secondary-button"
|
|
onClick={keepEditing}
|
|
ref={keepEditingRef}
|
|
type="button"
|
|
>
|
|
{t('unsaved.keepEditing')}
|
|
</button>
|
|
<button
|
|
className="danger-button"
|
|
onClick={discardAndLeave}
|
|
type="button"
|
|
>
|
|
{pendingLeave.kind !== 'navigate'
|
|
? t('unsaved.discardAndClose')
|
|
: t('unsaved.discardAndNavigate')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!categoryRendersOwnHeader && (
|
|
<SettingsCategoryHeader
|
|
actions={
|
|
configurationTab ? (
|
|
<>
|
|
{(activeTab === 'runtime' ||
|
|
(activeTab === 'model' && modelType === 'llm')) && (
|
|
<button
|
|
className="secondary-button"
|
|
disabled={saving || testing}
|
|
onClick={() => void testConnection()}
|
|
type="button"
|
|
>
|
|
{testing
|
|
? t('actions.testing')
|
|
: activeTab === 'model'
|
|
? t('actions.saveAndTestModel')
|
|
: t('actions.saveAndTestRuntime', {
|
|
runtime:
|
|
agentRuntimeType === 'opencode'
|
|
? 'OpenCode'
|
|
: agentRuntimeType === 'continue'
|
|
? 'Continue'
|
|
: 'DeepSeek Harness'
|
|
})}
|
|
</button>
|
|
)}
|
|
<button
|
|
className="primary-button"
|
|
disabled={saving || testing}
|
|
onClick={() => void save()}
|
|
type="button"
|
|
>
|
|
<Save aria-hidden="true" size={13} />
|
|
{saving
|
|
? t('actions.saving')
|
|
: t('actions.saveSettings')}
|
|
</button>
|
|
</>
|
|
) : undefined
|
|
}
|
|
category={activeTab}
|
|
error={error}
|
|
/>
|
|
)}
|
|
{activeTab === 'appearance' && (
|
|
<>
|
|
<div className="settings-section appearance-settings">
|
|
<div className="settings-section__title">
|
|
<SunMoon size={17} />
|
|
<div>
|
|
<strong>{t('appearance.theme.title')}</strong>
|
|
<small>{t('appearance.theme.description')}</small>
|
|
</div>
|
|
</div>
|
|
<div
|
|
aria-label={t('appearance.theme.ariaLabel')}
|
|
className="appearance-options"
|
|
role="radiogroup"
|
|
>
|
|
{(['system', 'light', 'dark'] as const).map(
|
|
(value) => (
|
|
<label key={value}>
|
|
<input
|
|
checked={appearanceTheme === value}
|
|
name="appearance-theme"
|
|
onChange={() =>
|
|
onAppearanceThemeChange(value)
|
|
}
|
|
type="radio"
|
|
value={value}
|
|
/>
|
|
<span
|
|
aria-hidden="true"
|
|
className={`appearance-options__preview appearance-options__preview--${value}`}
|
|
>
|
|
<i />
|
|
<i />
|
|
<i />
|
|
</span>
|
|
<strong>
|
|
{t(
|
|
`appearance.theme.options.${value}.label`
|
|
)}
|
|
</strong>
|
|
<small>
|
|
{t(
|
|
`appearance.theme.options.${value}.description`
|
|
)}
|
|
</small>
|
|
</label>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="settings-section appearance-settings">
|
|
<div className="settings-section__title">
|
|
<div>
|
|
<strong>{t('appearance.language.title')}</strong>
|
|
<small>{t('appearance.language.description')}</small>
|
|
</div>
|
|
</div>
|
|
<div
|
|
aria-label={t('appearance.language.ariaLabel')}
|
|
className="appearance-options"
|
|
role="radiogroup"
|
|
>
|
|
{(
|
|
[
|
|
['system', 'system'],
|
|
['zh-CN', 'chinese'],
|
|
['en-US', 'english']
|
|
] as const
|
|
).map(([value, translationKey]) => (
|
|
<label key={value}>
|
|
<input
|
|
checked={uiLocalePreference === value}
|
|
name="interface-language"
|
|
onChange={() => setUiLocalePreference(value)}
|
|
type="radio"
|
|
value={value}
|
|
/>
|
|
<strong>
|
|
{t(
|
|
`appearance.language.options.${translationKey}.label`
|
|
)}
|
|
</strong>
|
|
<small>
|
|
{t(
|
|
`appearance.language.options.${translationKey}.description`
|
|
)}
|
|
</small>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<BrandingSettingsSection
|
|
fallbackLogo={brandingFallbackLogo}
|
|
onChange={onBrandingPreferencesChange}
|
|
onDirtyChange={setBrandingDirty}
|
|
preferences={brandingPreferences}
|
|
/>
|
|
</>
|
|
)}
|
|
{activeTab === 'platform-features' && (
|
|
<PlatformFeaturesSettingsSection
|
|
onDirtyChange={setPlatformFeaturesDirty}
|
|
onConversationHtmlRenderingEnabledChange={
|
|
onConversationHtmlRenderingEnabledChange
|
|
}
|
|
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
|
|
onMagicNotesShowIncompleteTodoCountChange={
|
|
onMagicNotesShowIncompleteTodoCountChange
|
|
}
|
|
onRemoteProjectsEnabledChange={
|
|
onRemoteProjectsEnabledChange
|
|
}
|
|
onNotify={onNotify}
|
|
onShortcutSettingsChanged={onShortcutSettingsChanged}
|
|
/>
|
|
)}
|
|
{activeTab === 'runtime' && (
|
|
<>
|
|
<SettingsWarningList warnings={settings?.warnings} />
|
|
<div className="settings-section">
|
|
<div className="settings-section__title">
|
|
<FolderOpen size={17} />
|
|
<div>
|
|
<strong>{t('runtime.workspace.title')}</strong>
|
|
<small>{t('runtime.workspace.description')}</small>
|
|
</div>
|
|
</div>
|
|
<label className="field">
|
|
<span>{t('runtime.workspace.directoryLabel')}</span>
|
|
<div className="workspace-picker">
|
|
<input
|
|
aria-label={t('runtime.workspace.directoryLabel')}
|
|
onChange={(event) => setWorkspacePath(event.target.value)}
|
|
value={workspacePath}
|
|
/>
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => {
|
|
void window.goodbuddy.settings
|
|
.selectWorkspace()
|
|
.then((selected) => {
|
|
if (selected) {
|
|
setWorkspacePath(selected)
|
|
}
|
|
})
|
|
.catch((reason: unknown) => {
|
|
setError(
|
|
settingsErrorMessage(
|
|
reason,
|
|
t('errors.selectWorkspace')
|
|
)
|
|
)
|
|
})
|
|
}}
|
|
type="button"
|
|
>
|
|
{t('actions.select')}
|
|
</button>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="model-type-navigation agent-runtime-navigation">
|
|
<SegmentedControl
|
|
ariaLabel="Agent Runtime"
|
|
onChange={setAgentRuntimeType}
|
|
options={[
|
|
{ label: 'OpenCode', value: 'opencode' },
|
|
{ label: 'Continue', value: 'continue' },
|
|
{
|
|
label: t('runtime.deepseekHarness.selectorLabel'),
|
|
value: 'deepseek-harness'
|
|
}
|
|
]}
|
|
value={agentRuntimeType}
|
|
/>
|
|
</div>
|
|
|
|
{agentRuntimeType === 'opencode' && (
|
|
<div className="settings-section">
|
|
<div className="settings-section__title">
|
|
<TerminalSquare size={17} />
|
|
<div>
|
|
<strong>{t('runtime.opencode.title')}</strong>
|
|
<small>{t('runtime.bundledDescription')}</small>
|
|
</div>
|
|
</div>
|
|
<RuntimeOverviewCard
|
|
detection={detection?.opencode}
|
|
detectionLabel="OpenCode"
|
|
detecting={detecting}
|
|
modelConfiguration={
|
|
activeRuntimeModelSource.kind === 'platform'
|
|
? t('runtime.ownConfiguration', {
|
|
runtime: 'OpenCode'
|
|
})
|
|
: activeRuntimeModelProfile
|
|
? t('runtime.followGoodBuddy', {
|
|
name: modelProfileDisplayName(
|
|
activeRuntimeModelProfile
|
|
),
|
|
model: activeRuntimeModelProfile.modelName
|
|
})
|
|
: defaultTextModelProfile
|
|
? t('runtime.followGoodBuddy', {
|
|
name: modelProfileDisplayName(
|
|
defaultTextModelProfile
|
|
),
|
|
model: defaultTextModelProfile.modelName
|
|
})
|
|
: t('runtime.noCompatibleModel')
|
|
}
|
|
recommendation={t('runtime.opencode.recommendation')}
|
|
runtime={t('runtime.bundledRuntime', {
|
|
runtime: 'OpenCode'
|
|
})}
|
|
/>
|
|
<div className="runtime-note">
|
|
{t('runtime.permissions')}
|
|
</div>
|
|
<details className="settings-section">
|
|
<summary>{t('runtime.advanced')}</summary>
|
|
<p className="settings-panel__description">
|
|
{t('runtime.opencode.advancedDescription')}
|
|
</p>
|
|
<fieldset className="runtime-source-options">
|
|
<legend>{t('runtime.sourceLegend')}</legend>
|
|
<label>
|
|
<input
|
|
checked={opencodeModelSource.kind === 'profile'}
|
|
disabled={!defaultTextModelProfile}
|
|
name="opencode-model-source"
|
|
onChange={() => {
|
|
if (defaultTextModelProfile) {
|
|
setOpencodeModelSource({
|
|
kind: 'profile',
|
|
profileId: defaultTextModelProfile.id
|
|
})
|
|
setOpencodeBaseUrl('')
|
|
}
|
|
}}
|
|
type="radio"
|
|
/>
|
|
<span>
|
|
<strong>{t('runtime.followRecommended')}</strong>
|
|
<small>
|
|
{t('runtime.opencode.followDescription')}
|
|
</small>
|
|
</span>
|
|
</label>
|
|
<label>
|
|
<input
|
|
checked={opencodeModelSource.kind === 'platform'}
|
|
name="opencode-model-source"
|
|
onChange={() =>
|
|
setOpencodeModelSource({ kind: 'platform' })
|
|
}
|
|
type="radio"
|
|
/>
|
|
<span>
|
|
<strong>
|
|
{t('runtime.ownConfiguration', {
|
|
runtime: 'OpenCode'
|
|
})}
|
|
</strong>
|
|
<small>
|
|
{t('runtime.opencode.ownDescription')}
|
|
</small>
|
|
</span>
|
|
</label>
|
|
</fieldset>
|
|
{opencodeModelSource.kind === 'profile' && (
|
|
<label className="field">
|
|
<span>{t('runtime.goodBuddyConnection')}</span>
|
|
<select
|
|
aria-label={`OpenCode ${t(
|
|
'runtime.goodBuddyConnection'
|
|
)}`}
|
|
value={opencodeModelSource.profileId}
|
|
onChange={(event) => {
|
|
setOpencodeModelSource(
|
|
parseModelSource(event.target.value)
|
|
)
|
|
setOpencodeBaseUrl('')
|
|
}}
|
|
>
|
|
{modelProfiles.map((profile) => (
|
|
<option
|
|
disabled={!isOpenCodeCompatible(profile)}
|
|
key={profile.id}
|
|
value={profile.id}
|
|
>
|
|
{modelProfileDisplayName(profile)}
|
|
{isOpenCodeCompatible(profile)
|
|
? ''
|
|
: t('runtime.incompatibleSuffix')}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<small>
|
|
{t('runtime.pinConnectionDescription')}
|
|
</small>
|
|
</label>
|
|
)}
|
|
{opencodeModelSource.kind === 'platform' &&
|
|
!opencodeBaseUrl.trim() && (
|
|
<RuntimeConfigCard
|
|
description={t(
|
|
'runtime.opencode.configDescription'
|
|
)}
|
|
fileKind="opencodeConfig"
|
|
onOpen={openRuntimeConfig}
|
|
onPathChange={setOpencodeConfigPath}
|
|
onSelect={selectRuntimeFile}
|
|
path={opencodeConfigPath}
|
|
runtime="opencode"
|
|
runtimeLabel="OpenCode"
|
|
savedPath={
|
|
settings
|
|
? configuredRuntimeSettings(settings)
|
|
.opencodeConfigPath
|
|
: undefined
|
|
}
|
|
/>
|
|
)}
|
|
{opencodeModelSource.kind === 'platform' &&
|
|
Boolean(opencodeBaseUrl.trim()) && (
|
|
<p className="settings-warning">
|
|
{t('runtime.opencode.externalServerWarning')}
|
|
</p>
|
|
)}
|
|
<label className="field">
|
|
<span>{t('runtime.serverAddress')}</span>
|
|
<input
|
|
aria-label={t('runtime.opencode.serverAriaLabel')}
|
|
inputMode="url"
|
|
onChange={(event) => {
|
|
const value = event.target.value
|
|
setOpencodeBaseUrl(value)
|
|
if (value.trim()) {
|
|
setOpencodeModelSource({ kind: 'platform' })
|
|
}
|
|
}}
|
|
placeholder={t(
|
|
'runtime.opencode.serverPlaceholder'
|
|
)}
|
|
value={opencodeBaseUrl}
|
|
/>
|
|
<small>
|
|
{t('runtime.opencode.serverDescription')}
|
|
</small>
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('runtime.opencode.binaryPath')}</span>
|
|
<div className="workspace-picker">
|
|
<input
|
|
aria-label={t('runtime.opencode.binaryPath')}
|
|
onChange={(event) =>
|
|
setOpencodeBinaryPath(event.target.value)
|
|
}
|
|
placeholder={t(
|
|
'runtime.bundledProgramPlaceholder'
|
|
)}
|
|
value={opencodeBinaryPath}
|
|
/>
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() =>
|
|
void selectRuntimeFile(
|
|
'opencodeBinary',
|
|
setOpencodeBinaryPath
|
|
)
|
|
}
|
|
type="button"
|
|
>
|
|
{t('actions.select')}
|
|
</button>
|
|
<button
|
|
className="secondary-button"
|
|
disabled={!opencodeBinaryPath}
|
|
onClick={() => setOpencodeBinaryPath('')}
|
|
type="button"
|
|
>
|
|
{t('actions.clear')}
|
|
</button>
|
|
</div>
|
|
</label>
|
|
{opencodeBinaryPath && (
|
|
<p className="settings-warning">
|
|
{t('runtime.customBinaryWarning', {
|
|
runtime: 'OpenCode'
|
|
})}
|
|
</p>
|
|
)}
|
|
<button
|
|
className="secondary-button"
|
|
disabled={detecting}
|
|
onClick={() => void detectRuntimes()}
|
|
type="button"
|
|
>
|
|
{detecting
|
|
? t('actions.detecting')
|
|
: t('actions.redetectRuntime', {
|
|
runtime: 'OpenCode'
|
|
})}
|
|
</button>
|
|
</details>
|
|
</div>
|
|
)}
|
|
{agentRuntimeType === 'continue' && (
|
|
<div className="settings-section">
|
|
<div className="settings-section__title">
|
|
<TerminalSquare size={17} />
|
|
<div>
|
|
<strong>{t('runtime.continue.title')}</strong>
|
|
<small>{t('runtime.bundledDescription')}</small>
|
|
</div>
|
|
</div>
|
|
<RuntimeOverviewCard
|
|
detection={detection?.continue}
|
|
detectionLabel="Continue"
|
|
detecting={detecting}
|
|
modelConfiguration={
|
|
activeRuntimeModelSource.kind === 'platform'
|
|
? t('runtime.ownConfiguration', {
|
|
runtime: 'Continue'
|
|
})
|
|
: activeRuntimeModelProfile
|
|
? t('runtime.followGoodBuddy', {
|
|
name: modelProfileDisplayName(
|
|
activeRuntimeModelProfile
|
|
),
|
|
model: activeRuntimeModelProfile.modelName
|
|
})
|
|
: defaultTextModelProfile
|
|
? t('runtime.followGoodBuddy', {
|
|
name: modelProfileDisplayName(
|
|
defaultTextModelProfile
|
|
),
|
|
model: defaultTextModelProfile.modelName
|
|
})
|
|
: t('runtime.noCompatibleModel')
|
|
}
|
|
recommendation={t('runtime.continue.recommendation')}
|
|
runtime={t('runtime.bundledRuntime', {
|
|
runtime: 'Continue'
|
|
})}
|
|
/>
|
|
<div className="runtime-note">
|
|
{t('runtime.permissions')}
|
|
</div>
|
|
<details className="settings-section">
|
|
<summary>{t('runtime.advanced')}</summary>
|
|
<p className="settings-panel__description">
|
|
{t('runtime.continue.advancedDescription')}
|
|
</p>
|
|
<fieldset className="runtime-source-options">
|
|
<legend>{t('runtime.sourceLegend')}</legend>
|
|
<label>
|
|
<input
|
|
checked={continueModelSource.kind === 'profile'}
|
|
disabled={!defaultTextModelProfile}
|
|
name="continue-model-source"
|
|
onChange={() => {
|
|
if (defaultTextModelProfile) {
|
|
setContinueModelSource({
|
|
kind: 'profile',
|
|
profileId: defaultTextModelProfile.id
|
|
})
|
|
}
|
|
}}
|
|
type="radio"
|
|
/>
|
|
<span>
|
|
<strong>{t('runtime.followRecommended')}</strong>
|
|
<small>
|
|
{t('runtime.continue.followDescription')}
|
|
</small>
|
|
</span>
|
|
</label>
|
|
<label>
|
|
<input
|
|
checked={continueModelSource.kind === 'platform'}
|
|
name="continue-model-source"
|
|
onChange={() =>
|
|
setContinueModelSource({ kind: 'platform' })
|
|
}
|
|
type="radio"
|
|
/>
|
|
<span>
|
|
<strong>
|
|
{t('runtime.ownConfiguration', {
|
|
runtime: 'Continue'
|
|
})}
|
|
</strong>
|
|
<small>
|
|
{t('runtime.continue.ownDescription')}
|
|
</small>
|
|
</span>
|
|
</label>
|
|
</fieldset>
|
|
{continueModelSource.kind === 'profile' && (
|
|
<label className="field">
|
|
<span>{t('runtime.goodBuddyConnection')}</span>
|
|
<select
|
|
aria-label={`Continue ${t(
|
|
'runtime.goodBuddyConnection'
|
|
)}`}
|
|
value={continueModelSource.profileId}
|
|
onChange={(event) =>
|
|
setContinueModelSource(
|
|
parseModelSource(event.target.value)
|
|
)
|
|
}
|
|
>
|
|
{modelProfiles.map((profile) => (
|
|
<option
|
|
disabled={!isContinueCompatible(profile)}
|
|
key={profile.id}
|
|
value={profile.id}
|
|
>
|
|
{modelProfileDisplayName(profile)}
|
|
{isContinueCompatible(profile)
|
|
? ''
|
|
: t('runtime.incompatibleSuffix')}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<small>
|
|
{t('runtime.pinConnectionDescription')}
|
|
</small>
|
|
</label>
|
|
)}
|
|
{continueModelSource.kind === 'platform' && (
|
|
<RuntimeConfigCard
|
|
description={t(
|
|
'runtime.continue.configDescription'
|
|
)}
|
|
fileKind="continueConfig"
|
|
onOpen={openRuntimeConfig}
|
|
onPathChange={setContinueConfigPath}
|
|
onSelect={selectRuntimeFile}
|
|
path={continueConfigPath}
|
|
runtime="continue"
|
|
runtimeLabel="Continue"
|
|
savedPath={
|
|
settings
|
|
? configuredRuntimeSettings(settings)
|
|
.continueConfigPath
|
|
: undefined
|
|
}
|
|
/>
|
|
)}
|
|
<label className="field">
|
|
<span>{t('runtime.continue.binaryPath')}</span>
|
|
<div className="workspace-picker">
|
|
<input
|
|
aria-label={t('runtime.continue.binaryPath')}
|
|
onChange={(event) =>
|
|
setContinueBinaryPath(event.target.value)
|
|
}
|
|
placeholder={t(
|
|
'runtime.bundledProgramPlaceholder'
|
|
)}
|
|
value={continueBinaryPath}
|
|
/>
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() =>
|
|
void selectRuntimeFile(
|
|
'continueBinary',
|
|
setContinueBinaryPath
|
|
)
|
|
}
|
|
type="button"
|
|
>
|
|
{t('actions.select')}
|
|
</button>
|
|
<button
|
|
className="secondary-button"
|
|
disabled={!continueBinaryPath}
|
|
onClick={() => setContinueBinaryPath('')}
|
|
type="button"
|
|
>
|
|
{t('actions.clear')}
|
|
</button>
|
|
</div>
|
|
</label>
|
|
{continueModelSource.kind === 'platform' &&
|
|
!continueConfigPath && (
|
|
<p className="settings-warning">
|
|
{t('runtime.continue.missingConfigWarning')}
|
|
</p>
|
|
)}
|
|
{continueBinaryPath && (
|
|
<p className="settings-warning">
|
|
{t('runtime.customBinaryWarning', {
|
|
runtime: 'Continue'
|
|
})}
|
|
</p>
|
|
)}
|
|
<button
|
|
className="secondary-button"
|
|
disabled={detecting}
|
|
onClick={() => void detectRuntimes()}
|
|
type="button"
|
|
>
|
|
{detecting
|
|
? t('actions.detecting')
|
|
: t('actions.redetectRuntime', {
|
|
runtime: 'Continue'
|
|
})}
|
|
</button>
|
|
</details>
|
|
</div>
|
|
)}
|
|
{agentRuntimeType === 'deepseek-harness' && (
|
|
<div className="settings-section">
|
|
<div className="settings-section__title">
|
|
<TerminalSquare size={17} />
|
|
<div>
|
|
<strong>{t('runtime.deepseekHarness.title')}</strong>
|
|
<small>
|
|
{t('runtime.deepseekHarness.previewDescription')}
|
|
</small>
|
|
</div>
|
|
</div>
|
|
<RuntimeOverviewCard
|
|
detection={detection?.deepseekHarness}
|
|
detectionLabel="DeepSeek Harness"
|
|
detecting={detecting}
|
|
modelConfiguration={
|
|
activeRuntimeModelSource.kind === 'platform'
|
|
? t('runtime.deepseekHarness.managedSource')
|
|
: activeRuntimeModelProfile
|
|
? t('runtime.followGoodBuddy', {
|
|
name: modelProfileDisplayName(
|
|
activeRuntimeModelProfile
|
|
),
|
|
model: activeRuntimeModelProfile.modelName
|
|
})
|
|
: t('runtime.noCompatibleModel')
|
|
}
|
|
recommendation={t(
|
|
'runtime.deepseekHarness.description'
|
|
)}
|
|
runtime={t('runtime.bundledRuntime', {
|
|
runtime: 'DeepSeek Harness'
|
|
})}
|
|
/>
|
|
<label className="field">
|
|
<span>{t('runtime.deepseekHarness.connection')}</span>
|
|
<select
|
|
aria-label={`DeepSeek Harness ${t(
|
|
'runtime.deepseekHarness.connection'
|
|
)}`}
|
|
disabled={!defaultDeepseekHarnessModelProfile}
|
|
onChange={(event) =>
|
|
setDeepseekHarnessModelSource(
|
|
parseModelSource(event.target.value)
|
|
)
|
|
}
|
|
value={
|
|
deepseekHarnessModelSource.kind === 'profile'
|
|
? deepseekHarnessModelSource.profileId
|
|
: ''
|
|
}
|
|
>
|
|
<option disabled value="">
|
|
{t(
|
|
'runtime.deepseekHarness.connectionPlaceholder'
|
|
)}
|
|
</option>
|
|
{modelProfiles.map((profile) => (
|
|
<option
|
|
disabled={!isDeepseekHarnessCompatible(profile)}
|
|
key={profile.id}
|
|
value={profile.id}
|
|
>
|
|
{modelProfileDisplayName(profile)}
|
|
{isDeepseekHarnessCompatible(profile)
|
|
? ''
|
|
: t('runtime.incompatibleSuffix')}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<small>
|
|
{t(
|
|
'runtime.deepseekHarness.connectionDescription'
|
|
)}
|
|
</small>
|
|
</label>
|
|
<details className="settings-section">
|
|
<summary>{t('runtime.advanced')}</summary>
|
|
<p className="settings-panel__description">
|
|
{t(
|
|
'runtime.deepseekHarness.advancedDescription'
|
|
)}
|
|
</p>
|
|
<button
|
|
className="secondary-button"
|
|
disabled={detecting}
|
|
onClick={() => void detectRuntimes()}
|
|
type="button"
|
|
>
|
|
{detecting
|
|
? t('actions.detecting')
|
|
: t('actions.redetectRuntime', {
|
|
runtime: 'DeepSeek Harness'
|
|
})}
|
|
</button>
|
|
</details>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
<div hidden={activeTab !== 'runtime'}>
|
|
<RuntimeCustomizationSection
|
|
onDirtyChange={handleRuntimeCustomizationDirtyChange}
|
|
profileId={
|
|
activeRuntimeModelSource.kind === 'profile'
|
|
? activeRuntimeModelSource.profileId
|
|
: undefined
|
|
}
|
|
provider={agentRuntimeType}
|
|
ref={runtimeCustomizationRef}
|
|
/>
|
|
</div>
|
|
{activeTab === 'runtime' &&
|
|
agentRuntimeType === 'deepseek-harness' && (
|
|
<DshMarketplaceSection onNotify={onNotify} />
|
|
)}
|
|
|
|
{activeTab === 'model' && (
|
|
<>
|
|
<div className="model-type-navigation">
|
|
<SegmentedControl
|
|
ariaLabel={t('model.typeAriaLabel')}
|
|
onChange={setModelType}
|
|
options={[
|
|
{ label: t('model.types.llm.label'), value: 'llm' },
|
|
{
|
|
label: t('model.types.embedding.label'),
|
|
value: 'embedding'
|
|
},
|
|
{
|
|
label: t('model.types.rerank.label'),
|
|
value: 'rerank'
|
|
},
|
|
{
|
|
label: t('model.types.speech.label'),
|
|
value: 'speech'
|
|
}
|
|
]}
|
|
value={modelType}
|
|
/>
|
|
</div>
|
|
{modelType === 'llm' && (
|
|
<div className="settings-section">
|
|
<div className="settings-section__title settings-section__title--actions">
|
|
<KeyRound size={17} />
|
|
<div>
|
|
<strong>{t('model.profile.title')}</strong>
|
|
<small>
|
|
{t('model.profile.description')}
|
|
</small>
|
|
</div>
|
|
<button
|
|
className="secondary-button model-connection-add"
|
|
onClick={addModelProfile}
|
|
type="button"
|
|
>
|
|
<Plus size={14} />
|
|
{t('actions.addCustom')}
|
|
</button>
|
|
</div>
|
|
<div className="model-connection-manager">
|
|
<aside
|
|
aria-label={t('model.profile.listAriaLabel')}
|
|
className="model-connection-list"
|
|
>
|
|
<div className="model-connection-list__header">
|
|
<strong>{t('model.profile.listTitle')}</strong>
|
|
<span>{modelProfiles.length}</span>
|
|
</div>
|
|
<div role="list">
|
|
{modelProfiles.map((profile) => (
|
|
<div key={profile.id} role="listitem">
|
|
<button
|
|
aria-current={
|
|
selectedModelProfile?.id === profile.id
|
|
? 'page'
|
|
: undefined
|
|
}
|
|
aria-label={t('model.profile.editAriaLabel', {
|
|
name: modelProfileDisplayName(profile)
|
|
})}
|
|
onClick={() =>
|
|
setSelectedModelProfileId(profile.id)
|
|
}
|
|
type="button"
|
|
>
|
|
<span className="model-connection-list__name">
|
|
<strong>{modelProfileDisplayName(profile)}</strong>
|
|
<small>{profile.modelName}</small>
|
|
</span>
|
|
<span className="model-connection-list__badges">
|
|
{defaultModelProfileId === profile.id && (
|
|
<span>{t('model.profile.defaultBadge')}</span>
|
|
)}
|
|
{profile.protocol ===
|
|
'openai-images-generations' && (
|
|
<span>{t('model.profile.imageBadge')}</span>
|
|
)}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</aside>
|
|
{selectedModelProfile &&
|
|
(() => {
|
|
const profile = selectedModelProfile
|
|
const environmentManaged =
|
|
profile.credentialSource === 'environment'
|
|
const requestHeadersValid =
|
|
parseRequestHeadersDraft(
|
|
profile.requestHeadersText
|
|
).success
|
|
const requestBodyValid =
|
|
parseRequestBodyDraft(
|
|
profile.requestBodyText
|
|
).success
|
|
return (
|
|
<div
|
|
aria-labelledby={`model-connection-${profile.id}`}
|
|
className="model-connection-detail"
|
|
key={profile.id}
|
|
>
|
|
<div className="settings-section__title">
|
|
<div>
|
|
<strong id={`model-connection-${profile.id}`}>
|
|
{modelProfileDisplayName(profile)}
|
|
</strong>
|
|
<small>{t('model.profile.detail')}</small>
|
|
</div>
|
|
<label className="check-field">
|
|
<input
|
|
checked={defaultModelProfileId === profile.id}
|
|
name="default-model-profile"
|
|
onChange={() =>
|
|
selectDefaultModelProfile(profile)
|
|
}
|
|
type="radio"
|
|
/>
|
|
<span>{t('model.profile.defaultConnection')}</span>
|
|
</label>
|
|
{profile.protocol === 'openai-images-generations' && (
|
|
<span className="model-capability-badge">
|
|
{t('model.profile.imageGeneration')}
|
|
</span>
|
|
)}
|
|
<button
|
|
aria-label={t('model.profile.deleteAriaLabel', {
|
|
name: modelProfileDisplayName(profile)
|
|
})}
|
|
className="danger-button danger-button--quiet"
|
|
disabled={modelProfiles.length <= 1}
|
|
onClick={() => removeModelProfile(profile.id)}
|
|
type="button"
|
|
>
|
|
<Trash2 aria-hidden="true" size={14} />
|
|
{t('actions.deleteConnection')}
|
|
</button>
|
|
</div>
|
|
<label className="field">
|
|
<span>{t('model.profile.name')}</span>
|
|
<input
|
|
onChange={(event) =>
|
|
updateModelProfile(profile.id, {
|
|
name: event.target.value
|
|
})
|
|
}
|
|
value={modelProfileDisplayName(profile)}
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('model.profile.endpoint')}</span>
|
|
<input
|
|
disabled={environmentManaged}
|
|
inputMode="url"
|
|
onChange={(event) =>
|
|
updateModelProfile(profile.id, {
|
|
baseUrl: event.target.value
|
|
})
|
|
}
|
|
placeholder="https://api.example.com/v1"
|
|
value={profile.baseUrl}
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('model.profile.model')}</span>
|
|
<input
|
|
disabled={environmentManaged}
|
|
onChange={(event) =>
|
|
updateModelProfile(profile.id, {
|
|
modelName: event.target.value
|
|
})
|
|
}
|
|
placeholder="model-name"
|
|
value={profile.modelName}
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('model.profile.protocol')}</span>
|
|
<select
|
|
aria-label={t(
|
|
'model.profile.protocolAriaLabel',
|
|
{ name: modelProfileDisplayName(profile) }
|
|
)}
|
|
onChange={(event) =>
|
|
{
|
|
const protocol = event.target
|
|
.value as ModelProfileDraft['protocol']
|
|
updateModelProfile(profile.id, { protocol })
|
|
const compatibleFallback =
|
|
findPreferredCompatibleModelProfile(
|
|
modelProfiles.filter(
|
|
(candidate) =>
|
|
candidate.id !== profile.id
|
|
),
|
|
defaultModelProfileId,
|
|
(candidate) =>
|
|
isAgentRuntimeModelProtocol(
|
|
candidate.protocol
|
|
)
|
|
)
|
|
const runtimeFallback: RuntimeModelSource =
|
|
compatibleFallback
|
|
? {
|
|
kind: 'profile',
|
|
profileId: compatibleFallback.id
|
|
}
|
|
: { kind: 'platform' }
|
|
if (
|
|
!isAgentRuntimeModelProtocol(protocol) &&
|
|
opencodeModelSource.kind === 'profile' &&
|
|
opencodeModelSource.profileId === profile.id
|
|
) {
|
|
setOpencodeModelSource(runtimeFallback)
|
|
}
|
|
if (
|
|
!isAgentRuntimeModelProtocol(protocol) &&
|
|
continueModelSource.kind === 'profile' &&
|
|
continueModelSource.profileId === profile.id
|
|
) {
|
|
setContinueModelSource(runtimeFallback)
|
|
}
|
|
if (
|
|
protocol !== 'openai-chat-completions' &&
|
|
deepseekHarnessModelSource.kind ===
|
|
'profile' &&
|
|
deepseekHarnessModelSource.profileId ===
|
|
profile.id
|
|
) {
|
|
const harnessFallback =
|
|
modelProfiles.find(
|
|
(candidate) =>
|
|
candidate.id !== profile.id &&
|
|
candidate.protocol ===
|
|
'openai-chat-completions'
|
|
)
|
|
setDeepseekHarnessModelSource(
|
|
harnessFallback
|
|
? {
|
|
kind: 'profile',
|
|
profileId: harnessFallback.id
|
|
}
|
|
: { kind: 'platform' }
|
|
)
|
|
}
|
|
if (
|
|
!isAgentRuntimeModelProtocol(protocol) &&
|
|
contextCompression.modelSource.kind ===
|
|
'profile' &&
|
|
contextCompression.modelSource.profileId ===
|
|
profile.id
|
|
) {
|
|
setContextCompression((current) => ({
|
|
...current,
|
|
modelSource: { kind: 'current' }
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
value={profile.protocol}
|
|
>
|
|
<option value="openai-chat-completions">
|
|
{t('model.profile.openAiCompatibleProtocol')}
|
|
</option>
|
|
<option value="openai-responses">
|
|
OpenAI Responses
|
|
</option>
|
|
<option value="anthropic-messages">
|
|
Anthropic Messages
|
|
</option>
|
|
<option value="openai-images-generations">
|
|
{t('model.profile.imageProtocol')}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('model.profile.authentication')}</span>
|
|
<select
|
|
aria-label={t(
|
|
'model.profile.authenticationAriaLabel',
|
|
{ name: modelProfileDisplayName(profile) }
|
|
)}
|
|
onChange={(event) => {
|
|
const authentication = event.target
|
|
.value as ModelProfileDraft['authentication']
|
|
updateModelProfile(profile.id, {
|
|
authentication
|
|
})
|
|
}}
|
|
value={profile.authentication}
|
|
>
|
|
<option value="api-key">API Key</option>
|
|
<option value="none">
|
|
{t('credentials.noAuthentication')}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
{profile.authentication === 'api-key' ? (
|
|
<>
|
|
<label className="field">
|
|
<span>API Key</span>
|
|
<input
|
|
autoComplete="off"
|
|
disabled={
|
|
environmentManaged ||
|
|
!settings?.secureStorageAvailable
|
|
}
|
|
onChange={(event) =>
|
|
updateModelProfile(profile.id, {
|
|
apiKey: event.target.value,
|
|
clearApiKey: false
|
|
})
|
|
}
|
|
placeholder={
|
|
profile.apiKeyConfigured
|
|
? t('credentials.configuredPlaceholder')
|
|
: t('credentials.enterApiKey')
|
|
}
|
|
type="password"
|
|
value={profile.apiKey}
|
|
/>
|
|
</label>
|
|
<div className="credential-state">
|
|
<LockKeyhole size={15} />
|
|
<span>
|
|
{t(
|
|
`credentials.${profile.credentialSource}`
|
|
)}
|
|
</span>
|
|
{profile.credentialSource === 'encrypted' && (
|
|
<button
|
|
onClick={() =>
|
|
updateModelProfile(profile.id, {
|
|
apiKey: '',
|
|
clearApiKey: true
|
|
})
|
|
}
|
|
type="button"
|
|
>
|
|
{profile.clearApiKey
|
|
? t('actions.clearAfterSave')
|
|
: t('actions.clearCredential')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="credential-state">
|
|
<LockKeyhole size={15} />
|
|
<span>
|
|
{profile.apiKeyConfigured
|
|
? t('credentials.savedForConnection')
|
|
: t(
|
|
'credentials.noAuthenticationDescription'
|
|
)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{isAgentRuntimeModelProtocol(profile.protocol) && (
|
|
<>
|
|
<div className="field">
|
|
<label className="toggle-row">
|
|
<input
|
|
checked={profile.supportsImageInput}
|
|
onChange={(event) =>
|
|
updateModelProfile(profile.id, {
|
|
supportsImageInput: event.target.checked
|
|
})
|
|
}
|
|
role="switch"
|
|
type="checkbox"
|
|
/>
|
|
<span>{t('model.profile.supportsImageInput')}</span>
|
|
</label>
|
|
<small>
|
|
{t('model.profile.supportsImageInputDescription')}
|
|
</small>
|
|
</div>
|
|
<label className="field">
|
|
<span>{t('model.profile.contextWindow')}</span>
|
|
<input
|
|
aria-label={t('model.profile.contextWindow')}
|
|
inputMode="numeric"
|
|
max={10_000}
|
|
min={32}
|
|
onChange={(event) => {
|
|
const value = event.target.valueAsNumber
|
|
updateModelProfile(profile.id, {
|
|
contextWindowTokens: Number.isFinite(value)
|
|
? Math.round(value * 1_000)
|
|
: undefined
|
|
})
|
|
}}
|
|
placeholder="200"
|
|
type="number"
|
|
value={
|
|
profile.contextWindowTokens === undefined
|
|
? ''
|
|
: profile.contextWindowTokens / 1_000
|
|
}
|
|
/>
|
|
<small>
|
|
{t('model.profile.contextWindowDescription')}
|
|
</small>
|
|
</label>
|
|
{profile.protocol === 'anthropic-messages' && (
|
|
<label className="field">
|
|
<span>{t('model.profile.maximumOutput')}</span>
|
|
<input
|
|
aria-label={t('model.profile.maximumOutput')}
|
|
inputMode="numeric"
|
|
max={10_000}
|
|
min={1}
|
|
onChange={(event) => {
|
|
const value = event.target.valueAsNumber
|
|
updateModelProfile(profile.id, {
|
|
maximumOutputTokens: Number.isFinite(value)
|
|
? Math.round(value * 1_000)
|
|
: defaultAnthropicMaximumOutputTokens
|
|
})
|
|
}}
|
|
placeholder="32"
|
|
type="number"
|
|
value={
|
|
(profile.maximumOutputTokens ??
|
|
defaultAnthropicMaximumOutputTokens) / 1_000
|
|
}
|
|
/>
|
|
<small>
|
|
{t('model.profile.maximumOutputDescription')}
|
|
</small>
|
|
</label>
|
|
)}
|
|
</>
|
|
)}
|
|
{profile.protocol ===
|
|
'openai-images-generations' && (
|
|
<label className="field">
|
|
<span>{t('model.profile.imageQuality')}</span>
|
|
<select
|
|
aria-label={t(
|
|
'model.profile.imageQualityAriaLabel',
|
|
{ name: modelProfileDisplayName(profile) }
|
|
)}
|
|
onChange={(event) =>
|
|
updateModelProfile(profile.id, {
|
|
imageGenerationQuality: event.target
|
|
.value as ModelProfileDraft['imageGenerationQuality']
|
|
})
|
|
}
|
|
value={profile.imageGenerationQuality}
|
|
>
|
|
<option value="auto">
|
|
{t('model.profile.quality.auto')}
|
|
</option>
|
|
<option value="low">
|
|
{t('model.profile.quality.low')}
|
|
</option>
|
|
<option value="medium">
|
|
{t('model.profile.quality.medium')}
|
|
</option>
|
|
<option value="high">
|
|
{t('model.profile.quality.high')}
|
|
</option>
|
|
</select>
|
|
<small>
|
|
{t('model.profile.imageQualityDescription')}
|
|
</small>
|
|
</label>
|
|
)}
|
|
<label className="field">
|
|
<span>{t('model.profile.requestHeaders')}</span>
|
|
<textarea
|
|
aria-describedby={`model-request-headers-help-${profile.id}`}
|
|
aria-label={t('model.profile.requestHeaders')}
|
|
aria-invalid={
|
|
requestHeadersValid ? undefined : true
|
|
}
|
|
className="model-request-json-input"
|
|
maxLength={maximumRequestHeadersDraftCharacters}
|
|
onChange={(event) =>
|
|
updateModelProfile(profile.id, {
|
|
requestHeadersText: event.target.value
|
|
})
|
|
}
|
|
rows={6}
|
|
spellCheck={false}
|
|
value={profile.requestHeadersText}
|
|
/>
|
|
<small
|
|
id={`model-request-headers-help-${profile.id}`}
|
|
>
|
|
{t('model.profile.requestHeadersDescription')}
|
|
</small>
|
|
{!requestHeadersValid && (
|
|
<small className="field-error" role="alert">
|
|
{t('model.profile.requestHeadersInvalid')}
|
|
</small>
|
|
)}
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('model.profile.requestBody')}</span>
|
|
<textarea
|
|
aria-describedby={`model-request-body-help-${profile.id}`}
|
|
aria-label={t('model.profile.requestBody')}
|
|
aria-invalid={
|
|
requestBodyValid ? undefined : true
|
|
}
|
|
className="model-request-json-input"
|
|
maxLength={maximumRequestBodyDraftCharacters}
|
|
onChange={(event) =>
|
|
updateModelProfile(profile.id, {
|
|
requestBodyText: event.target.value
|
|
})
|
|
}
|
|
rows={6}
|
|
spellCheck={false}
|
|
value={profile.requestBodyText}
|
|
/>
|
|
<small
|
|
id={`model-request-body-help-${profile.id}`}
|
|
>
|
|
{t('model.profile.requestBodyDescription')}
|
|
</small>
|
|
{!requestBodyValid && (
|
|
<small className="field-error" role="alert">
|
|
{t('model.profile.requestBodyInvalid')}
|
|
</small>
|
|
)}
|
|
</label>
|
|
<p className="settings-warning">
|
|
{t('model.profile.requestCustomizationWarning')}
|
|
</p>
|
|
<small className="model-connection-detail__compatibility">
|
|
{t('model.profile.compatibilitySummary', {
|
|
directCapability:
|
|
profile.protocol ===
|
|
'openai-images-generations'
|
|
? t('model.profile.imageGeneration')
|
|
: t('model.profile.textChat'),
|
|
continueCompatibility: isContinueCompatible(profile)
|
|
? t('model.profile.compatible')
|
|
: t('model.profile.incompatible'),
|
|
openCodeCompatibility: isOpenCodeCompatible(profile)
|
|
? t('model.profile.compatible')
|
|
: t('model.profile.incompatibleImageProtocol'),
|
|
deepseekHarnessCompatibility:
|
|
isDeepseekHarnessCompatible(profile)
|
|
? t('model.profile.compatible')
|
|
: t(
|
|
'model.profile.incompatibleHarnessProtocol'
|
|
)
|
|
})}
|
|
</small>
|
|
</div>
|
|
)
|
|
})()}
|
|
</div>
|
|
{settings && !settings.secureStorageAvailable && (
|
|
<p className="settings-warning">
|
|
{t('model.profile.secureStorageWarning')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
{modelType === 'embedding' && (
|
|
<EmbeddingSettingsSection
|
|
connections={[
|
|
...(embeddingSnapshot?.connections ?? [])
|
|
.filter((connection) => connection.kind === 'builtin')
|
|
.map((connection) => {
|
|
const installed = embeddingModels?.installed.some(
|
|
(candidate) => candidate.id === connection.model
|
|
)
|
|
const operation = embeddingModels?.operations.find(
|
|
(candidate) =>
|
|
candidate.modelId === connection.model
|
|
)
|
|
const progress =
|
|
operation?.totalBytes &&
|
|
Math.round(
|
|
(operation.completedBytes /
|
|
operation.totalBytes) *
|
|
100
|
|
)
|
|
return {
|
|
...connection,
|
|
installed,
|
|
statusText: operation
|
|
? t('model.embedding.progress', {
|
|
percent: progress ?? 0
|
|
})
|
|
: installed
|
|
? t('model.embedding.installed')
|
|
: t('model.embedding.notInstalled')
|
|
}
|
|
}),
|
|
...embeddingConnections.map((connection) => ({
|
|
...connection,
|
|
model: connection.modelName,
|
|
endpoint: connection.baseUrl
|
|
}))
|
|
]}
|
|
currentConnectionId={activeEmbeddingConnectionId}
|
|
diagnostic={embeddingDiagnostic}
|
|
diagnosticRunning={embeddingDiagnosticRunning}
|
|
enabled={knowledgeEmbeddingEnabled}
|
|
models={embeddingModels}
|
|
pendingOperation={embeddingModelPendingOperation}
|
|
busy={saving || embeddingModelActionRunning}
|
|
secureStorageAvailable={
|
|
settings?.secureStorageAvailable ?? false
|
|
}
|
|
selectedConnectionId={editingEmbeddingConnectionId}
|
|
onAddConnection={() => {
|
|
const id = crypto.randomUUID()
|
|
setEmbeddingConnections((connections) => [
|
|
...connections,
|
|
{
|
|
id,
|
|
name: t('model.embedding.newConnectionName'),
|
|
kind: 'openai-compatible',
|
|
baseUrl:
|
|
'https://provider.example/v1/embeddings',
|
|
modelName: 'text-embedding-3-small',
|
|
authentication: 'api-key',
|
|
apiKeyConfigured: false,
|
|
credentialSource: 'none',
|
|
apiKey: '',
|
|
clearApiKey: false
|
|
}
|
|
])
|
|
setEditingEmbeddingConnectionId(id)
|
|
}}
|
|
onCancelBuiltin={(modelId) => {
|
|
void cancelEmbeddingModelOperation(modelId)
|
|
}}
|
|
onDeleteConnection={(connectionId) => {
|
|
setEmbeddingConnections((connections) =>
|
|
connections.filter(
|
|
(connection) => connection.id !== connectionId
|
|
)
|
|
)
|
|
if (activeEmbeddingConnectionId === connectionId) {
|
|
setActiveEmbeddingConnectionId(
|
|
builtinEmbeddingConnectionId
|
|
)
|
|
}
|
|
setEditingEmbeddingConnectionId(
|
|
builtinEmbeddingConnectionId
|
|
)
|
|
}}
|
|
onDownloadBuiltin={(modelId) => {
|
|
if (!embeddingModels) {
|
|
setError(t('errors.readEmbeddingStatus'))
|
|
return
|
|
}
|
|
const model = embeddingModels?.catalog.find(
|
|
(candidate) => candidate.id === modelId
|
|
)
|
|
void runEmbeddingModelOperation(
|
|
modelId,
|
|
'download',
|
|
(api) =>
|
|
api.installModel(
|
|
modelId,
|
|
embeddingModels.selectedDownloadSource
|
|
),
|
|
i18n.t('embedding.notifications.installed', {
|
|
ns: 'settingsSections',
|
|
name: model?.displayName ?? modelId
|
|
})
|
|
)
|
|
}}
|
|
onEnabledChange={setKnowledgeEmbeddingEnabled}
|
|
onImportBuiltin={(modelId) => {
|
|
const name =
|
|
embeddingModels?.catalog.find(
|
|
(candidate) => candidate.id === modelId
|
|
)?.displayName ?? modelId
|
|
void runEmbeddingModelOperation(
|
|
modelId,
|
|
'import',
|
|
(api) => api.importModelArchive(modelId),
|
|
i18n.t('embedding.notifications.importedZip', {
|
|
ns: 'settingsSections',
|
|
name
|
|
})
|
|
)
|
|
}}
|
|
onRemoveBuiltin={(modelId) => {
|
|
const name =
|
|
embeddingModels?.catalog.find(
|
|
(candidate) => candidate.id === modelId
|
|
)?.displayName ?? modelId
|
|
void runEmbeddingModelOperation(
|
|
modelId,
|
|
undefined,
|
|
(api) => api.removeModel(modelId),
|
|
i18n.t('embedding.notifications.removed', {
|
|
ns: 'settingsSections',
|
|
name
|
|
})
|
|
)
|
|
}}
|
|
onOpenModelDownloadSourceSettings={() =>
|
|
requestTabChange('platform-features')
|
|
}
|
|
onSelectConnection={setEditingEmbeddingConnectionId}
|
|
onSetCurrent={setActiveEmbeddingConnectionId}
|
|
onTestConnection={(connectionId) => {
|
|
void runEmbeddingDiagnostic(connectionId)
|
|
}}
|
|
onUpdateConnection={(connectionId, changes) => {
|
|
setEmbeddingConnections((connections) =>
|
|
connections.map((connection) =>
|
|
connection.id === connectionId
|
|
? {
|
|
...connection,
|
|
...(changes.name === undefined
|
|
? {}
|
|
: { name: changes.name }),
|
|
...(changes.endpoint === undefined
|
|
? {}
|
|
: { baseUrl: changes.endpoint }),
|
|
...(changes.model === undefined
|
|
? {}
|
|
: { modelName: changes.model }),
|
|
...(changes.authentication === undefined
|
|
? {}
|
|
: {
|
|
authentication:
|
|
changes.authentication
|
|
}),
|
|
...(changes.apiKey === undefined
|
|
? {}
|
|
: { apiKey: changes.apiKey }),
|
|
...(changes.clearApiKey === undefined
|
|
? {}
|
|
: {
|
|
clearApiKey: changes.clearApiKey
|
|
})
|
|
}
|
|
: connection
|
|
)
|
|
)
|
|
}}
|
|
/>
|
|
)}
|
|
{modelType === 'rerank' && (
|
|
<div className="settings-section">
|
|
<div className="settings-section__title">
|
|
<KeyRound size={17} />
|
|
<div>
|
|
<strong>{t('model.rerank.title')}</strong>
|
|
<small>{t('model.rerank.description')}</small>
|
|
</div>
|
|
</div>
|
|
<div className="runtime-note model-service-form">
|
|
<label className="toggle-row">
|
|
<input
|
|
checked={knowledgeRerankEnabled}
|
|
onChange={(event) =>
|
|
setKnowledgeRerankEnabled(event.target.checked)
|
|
}
|
|
role="switch"
|
|
type="checkbox"
|
|
/>
|
|
<span>{t('model.rerank.enabled')}</span>
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('model.rerank.endpoint')}</span>
|
|
<input
|
|
aria-label={t('model.rerank.endpoint')}
|
|
disabled={!knowledgeRerankEnabled}
|
|
inputMode="url"
|
|
onChange={(event) =>
|
|
setKnowledgeRerankEndpoint(event.target.value)
|
|
}
|
|
placeholder="https://api.cohere.com/v1/rerank"
|
|
value={knowledgeRerankEndpoint}
|
|
/>
|
|
<small>{t('model.rerank.endpointDescription')}</small>
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('model.rerank.modelName')}</span>
|
|
<input
|
|
aria-label={t('model.rerank.modelName')}
|
|
disabled={!knowledgeRerankEnabled}
|
|
onChange={(event) =>
|
|
setKnowledgeRerankModel(event.target.value)
|
|
}
|
|
value={knowledgeRerankModel}
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('model.rerank.optionalApiKey')}</span>
|
|
<input
|
|
aria-label={t('model.rerank.optionalApiKey')}
|
|
autoComplete="off"
|
|
disabled={
|
|
!knowledgeRerankEnabled ||
|
|
settings?.knowledgeRerankCredentialSource ===
|
|
'environment' ||
|
|
!settings?.secureStorageAvailable
|
|
}
|
|
onChange={(event) => {
|
|
setKnowledgeRerankApiKey(event.target.value)
|
|
setClearKnowledgeRerankApiKey(false)
|
|
}}
|
|
placeholder={
|
|
settings?.knowledgeRerankApiKeyConfigured
|
|
? t('credentials.configuredPlaceholder')
|
|
: t('model.rerank.optionalApiKeyPlaceholder')
|
|
}
|
|
type="password"
|
|
value={knowledgeRerankApiKey}
|
|
/>
|
|
</label>
|
|
<div className="credential-state" aria-live="polite">
|
|
<LockKeyhole size={15} />
|
|
<span>
|
|
{settings
|
|
? t(
|
|
`credentials.${settings.knowledgeRerankCredentialSource ?? 'none'}`
|
|
)
|
|
: t('credentials.none')}
|
|
</span>
|
|
{settings?.knowledgeRerankCredentialSource ===
|
|
'encrypted' && (
|
|
<button
|
|
onClick={() => {
|
|
setKnowledgeRerankApiKey('')
|
|
setClearKnowledgeRerankApiKey(true)
|
|
}}
|
|
type="button"
|
|
>
|
|
{clearKnowledgeRerankApiKey
|
|
? t('actions.clearAfterSave')
|
|
: t('actions.clearCredential')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<small>{t('model.rerank.privacyDescription')}</small>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{modelType === 'speech' && (
|
|
<SpeechModelSettingsSection
|
|
onNotify={onNotify}
|
|
onOpenModelDownloadSourceSettings={() =>
|
|
requestTabChange('platform-features')
|
|
}
|
|
onSelectedModelIdChange={(modelId, changed) => {
|
|
setSpeechModelDraftId(modelId)
|
|
setSpeechModelSelectionDirty(changed)
|
|
}}
|
|
onSelectionInvalidated={(modelId) => {
|
|
setSpeechModelDraftId(modelId)
|
|
setPersistedSpeechModelId(modelId)
|
|
setSpeechModelSelectionDirty(false)
|
|
}}
|
|
persistedSelectedModelId={persistedSpeechModelId}
|
|
selectedModelId={speechModelDraftId}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{activeTab === 'context-control' && (
|
|
<>
|
|
<div className="settings-section">
|
|
<div className="field">
|
|
<label className="toggle-row">
|
|
<input
|
|
checked={contextCompression.enabled}
|
|
onChange={(event) =>
|
|
setContextCompression((current) => ({
|
|
...current,
|
|
enabled: event.target.checked
|
|
}))
|
|
}
|
|
role="switch"
|
|
type="checkbox"
|
|
/>
|
|
<span>{t('contextControl.enabled')}</span>
|
|
</label>
|
|
<small>{t('contextControl.enabledDescription')}</small>
|
|
<small>{t('contextControl.usageNotice')}</small>
|
|
</div>
|
|
</div>
|
|
<div className="settings-section">
|
|
<label className="field">
|
|
<span>{t('contextControl.triggerTokens')}</span>
|
|
<input
|
|
aria-label={t('contextControl.triggerTokens')}
|
|
disabled={!contextCompression.enabled}
|
|
inputMode="numeric"
|
|
max={maximumContextCompressionTriggerThousands}
|
|
min={minimumContextCompressionTriggerThousands}
|
|
onBlur={commitContextCompressionTokenInput}
|
|
onChange={(event) =>
|
|
setContextCompressionTokenInput((current) => ({
|
|
...current,
|
|
trigger: event.target.value
|
|
}))
|
|
}
|
|
required
|
|
step={1}
|
|
type="number"
|
|
value={contextCompressionTokenInput.trigger}
|
|
/>
|
|
<small>
|
|
{t('contextControl.triggerTokensDescription', {
|
|
tokens:
|
|
normalizedContextCompression.triggerTokens
|
|
.toLocaleString(i18n.language)
|
|
})}
|
|
</small>
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('contextControl.recentRawTokens')}</span>
|
|
<input
|
|
aria-label={t('contextControl.recentRawTokens')}
|
|
disabled={!contextCompression.enabled}
|
|
inputMode="numeric"
|
|
max={maximumContextCompressionRecentRawThousandsFor(
|
|
normalizedContextCompression.triggerTokens
|
|
)}
|
|
min={minimumContextCompressionRecentRawThousands}
|
|
onBlur={commitContextCompressionTokenInput}
|
|
onChange={(event) =>
|
|
setContextCompressionTokenInput((current) => ({
|
|
...current,
|
|
recentRaw: event.target.value
|
|
}))
|
|
}
|
|
required
|
|
step={1}
|
|
type="number"
|
|
value={contextCompressionTokenInput.recentRaw}
|
|
/>
|
|
<small>
|
|
{t('contextControl.recentRawTokensDescription', {
|
|
tokens:
|
|
normalizedContextCompression.recentRawTokens
|
|
.toLocaleString(i18n.language)
|
|
})}
|
|
</small>
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('contextControl.summaryModel')}</span>
|
|
<select
|
|
aria-label={t('contextControl.summaryModel')}
|
|
disabled={!contextCompression.enabled}
|
|
onChange={(event) =>
|
|
setContextCompression((current) => ({
|
|
...current,
|
|
modelSource:
|
|
event.target.value === 'current'
|
|
? { kind: 'current' }
|
|
: {
|
|
kind: 'profile',
|
|
profileId: event.target.value
|
|
}
|
|
}))
|
|
}
|
|
value={
|
|
contextCompression.modelSource.kind === 'current'
|
|
? 'current'
|
|
: contextCompression.modelSource.profileId
|
|
}
|
|
>
|
|
<option value="current">
|
|
{t('contextControl.currentModel')}
|
|
</option>
|
|
{modelProfiles
|
|
.filter((profile) =>
|
|
isAgentRuntimeModelProtocol(profile.protocol)
|
|
)
|
|
.map((profile) => (
|
|
<option key={profile.id} value={profile.id}>
|
|
{modelProfileDisplayName(profile)} ·{' '}
|
|
{profile.modelName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<small>{t('contextControl.summaryModelDescription')}</small>
|
|
</label>
|
|
<p className="settings-panel__description">
|
|
{t('contextControl.fixedTarget')}
|
|
</p>
|
|
</div>
|
|
<div className="runtime-note">
|
|
<p>{t('contextControl.modelLimits')}</p>
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => {
|
|
setModelType('llm')
|
|
requestTabChange('model')
|
|
}}
|
|
type="button"
|
|
>
|
|
{t('contextControl.manageModelLimits')}
|
|
</button>
|
|
</div>
|
|
<details className="settings-section">
|
|
<summary>{t('contextControl.advanced')}</summary>
|
|
<label className="field">
|
|
<span>{t('contextControl.summaryPrompt')}</span>
|
|
<textarea
|
|
disabled={!contextCompression.enabled}
|
|
onChange={(event) =>
|
|
setContextCompression((current) => ({
|
|
...current,
|
|
summaryPrompt: event.target.value
|
|
}))
|
|
}
|
|
rows={7}
|
|
value={contextCompression.summaryPrompt}
|
|
/>
|
|
<small>
|
|
{t('contextControl.summaryPromptDescription')}
|
|
</small>
|
|
</label>
|
|
<button
|
|
className="secondary-button"
|
|
disabled={
|
|
!contextCompression.enabled ||
|
|
contextCompression.summaryPrompt ===
|
|
defaultContextCompressionSettings.summaryPrompt
|
|
}
|
|
onClick={() =>
|
|
setContextCompression((current) => ({
|
|
...current,
|
|
summaryPrompt:
|
|
defaultContextCompressionSettings.summaryPrompt
|
|
}))
|
|
}
|
|
type="button"
|
|
>
|
|
{t('contextControl.restoreDefaultPrompt')}
|
|
</button>
|
|
</details>
|
|
</>
|
|
)}
|
|
|
|
{activeTab === 'document-parsing' && (
|
|
<DocumentParsingSettingsSection
|
|
onDirtyChange={setDocumentParsingDirty}
|
|
onNotify={onNotify}
|
|
onOpenModelDownloadSourceSettings={() =>
|
|
requestTabChange('platform-features')
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{remoteProjectsEnabled && activeTab === 'ssh-hosts' && (
|
|
<SshHostsSettingsSection
|
|
onDirtyChange={setSshHostsDirty}
|
|
onNotify={onNotify}
|
|
onProjectsDeleted={onProjectsDeleted}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'security' && (
|
|
<>
|
|
<div className="settings-section">
|
|
<label className="field">
|
|
<span>{t('security.toolPolicy.label')}</span>
|
|
<select
|
|
aria-label={t('security.toolPolicy.label')}
|
|
value={toolApproval}
|
|
onChange={(event) =>
|
|
setToolApproval(
|
|
event.target.value as RuntimeSettingsInput['toolApproval']
|
|
)
|
|
}
|
|
>
|
|
<option value="always">
|
|
{t('security.toolPolicy.always')}
|
|
</option>
|
|
<option value="policy">
|
|
{t('security.toolPolicy.deny')}
|
|
</option>
|
|
</select>
|
|
<small>
|
|
{t('security.toolPolicy.description')}
|
|
</small>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="settings-section settings-section--danger">
|
|
<div>
|
|
<strong>{t('security.localData.title')}</strong>
|
|
<p>
|
|
{t('security.localData.description')}
|
|
</p>
|
|
</div>
|
|
{confirmingClear ? (
|
|
<div className="danger-actions">
|
|
<button
|
|
className="secondary-button"
|
|
disabled={clearingLocalData}
|
|
onClick={() => setConfirmingClear(false)}
|
|
type="button"
|
|
>
|
|
{t('actions.cancel')}
|
|
</button>
|
|
<button
|
|
className="danger-button"
|
|
disabled={clearingLocalData}
|
|
onClick={() => {
|
|
setClearingLocalData(true)
|
|
setError(undefined)
|
|
void onClearLocalData()
|
|
.then(() => {
|
|
setConfirmingClear(false)
|
|
})
|
|
.catch((reason: unknown) => {
|
|
setError(
|
|
settingsErrorMessage(
|
|
reason,
|
|
t('errors.clearLocalData')
|
|
)
|
|
)
|
|
})
|
|
.finally(() => setClearingLocalData(false))
|
|
}}
|
|
type="button"
|
|
>
|
|
{clearingLocalData
|
|
? t('actions.clearing')
|
|
: t('actions.clearLocalData')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
className="danger-button"
|
|
onClick={() => setConfirmingClear(true)}
|
|
type="button"
|
|
>
|
|
{t('actions.clearLocalData')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
{activeTab === 'channels' && (
|
|
<ChannelSettingsSection
|
|
initialChannel={initialChannel}
|
|
onNotify={onNotify}
|
|
onUpdateProject={onUpdateProject}
|
|
projectList={projects}
|
|
/>
|
|
)}
|
|
{activeTab === 'roles' && (
|
|
<>
|
|
<div className="settings-section subagent-routing-settings">
|
|
<div className="settings-section__title">
|
|
<div>
|
|
<strong>{t('roles.smartRouting.title')}</strong>
|
|
<small>
|
|
{t('roles.smartRouting.description')}
|
|
</small>
|
|
</div>
|
|
</div>
|
|
<label className="toggle-row">
|
|
<input
|
|
aria-describedby="subagent-smart-routing-help"
|
|
checked={subagentSmartRoutingEnabled}
|
|
onChange={(event) =>
|
|
setSubagentSmartRoutingEnabled(event.target.checked)
|
|
}
|
|
role="switch"
|
|
type="checkbox"
|
|
/>
|
|
<span>{t('roles.smartRouting.enabled')}</span>
|
|
</label>
|
|
<small id="subagent-smart-routing-help">
|
|
{t('roles.smartRouting.help')}
|
|
</small>
|
|
</div>
|
|
<RolePromptSettingsSection
|
|
defaultModelProfileId={savedRoleDefaultModelProfileId}
|
|
modelProfiles={savedRoleModelProfiles}
|
|
onChanged={onExpertsChanged}
|
|
/>
|
|
</>
|
|
)}
|
|
{activeTab === 'capabilities' && (
|
|
<CapabilitiesAndToolsSettingsSection
|
|
magicNotesEnabled={magicNotesEnabled}
|
|
onNotify={onNotify}
|
|
/>
|
|
)}
|
|
{activeTab === 'about' && (
|
|
<UpdateSettingsSection onNotify={onNotify} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|