Skip to content
Open
1 change: 1 addition & 0 deletions apps/admin/src/api/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export interface ProviderModel {
}

export interface ProviderModelsResponse {
embeddingModels?: ProviderModel[]
error?: string
models: ProviderModel[]
providerId: string
Expand Down
224 changes: 219 additions & 5 deletions apps/admin/src/features/settings/components/ai/AIConfigEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,23 +36,52 @@ export function AIConfigEditor(props: {
enabled: hasEnabledProvider,
queryFn: async () => {
const response = await getModels()
return response.reduce<Record<string, AIProviderModel[]>>(
return response.reduce<{
chat: Record<string, AIProviderModel[]>
embedding: Record<string, AIProviderModel[]>
}>(
(result, provider) => ({
...result,
[provider.providerId]: provider.models ?? [],
chat: {
...result.chat,
[provider.providerId]: provider.models ?? [],
},
embedding: {
...result.embedding,
[provider.providerId]: provider.embeddingModels ?? [],
},
}),
{},
{ chat: {}, embedding: {} },
)
},
queryKey: props.modelCacheKey,
staleTime: 24 * 60 * 60 * 1000,
})
const providerModels = modelsQuery.data ?? {}
const providerModels = modelsQuery.data?.chat ?? {}
const embeddingProviderModels = modelsQuery.data?.embedding ?? {}
const providers = props.value.providers ?? []

const updateConfig = (patch: Partial<AIConfig>) =>
props.onChange({ ...props.value, ...patch })

const updateNumber = (key: keyof AIConfig, raw: string) => {
const trimmed = raw.trim()
updateConfig({ [key]: trimmed ? Number(trimmed) : undefined })
}

const updateNestedNumber = (
section: 'aiEmbedding' | 'aiMemory' | 'aiPersona',
key: string,
raw: string,
) => {
const trimmed = raw.trim()
updateConfig({
[section]: {
...(props.value[section] ?? {}),
[key]: trimmed ? Number(trimmed) : undefined,
},
})
}

const updateProvider = (id: string, patch: Partial<AIProviderConfig>) => {
updateConfig({
providers: providers.map((provider) =>
Expand Down Expand Up @@ -335,6 +364,183 @@ export function AIConfigEditor(props: {
/>
</FeatureSection>

<FeatureSection
assignment={
<>
<AIModelAssignmentField
label={t('settings.ai.assignment.echoLabel')}
models={providerModels}
onChange={(echoModel) => updateConfig({ echoModel })}
providers={providers}
value={props.value.echoModel}
/>
<AIModelAssignmentField
description={t('settings.ai.assignment.embeddingDescription')}
label={t('settings.ai.assignment.embeddingLabel')}
models={embeddingProviderModels}
onChange={(embeddingModel) => updateConfig({ embeddingModel })}
providers={providers}
value={props.value.embeddingModel}
/>
<AIModelAssignmentField
label={t('settings.ai.assignment.personaDistillLabel')}
models={providerModels}
onChange={(personaDistillModel) =>
updateConfig({ personaDistillModel })
}
providers={providers}
value={props.value.personaDistillModel}
/>
</>
}
description={t('settings.ai.section.echoDescription')}
enabled={Boolean(props.value.enableEcho)}
onEnabledChange={(enableEcho) => updateConfig({ enableEcho })}
title={t('settings.ai.section.echo')}
toggleLabel={t('settings.ai.switch.enableEcho')}
>
<Switch
checked={Boolean(props.value.enableAutoGenerateEchoOnCreate)}
disabled={!props.value.enableEcho}
label={t('settings.ai.switch.enableAutoEchoCreate')}
onCheckedChange={(enableAutoGenerateEchoOnCreate) =>
updateConfig({ enableAutoGenerateEchoOnCreate })
}
/>
<NumberGrid>
<TextInput
disabled={!props.value.enableEcho}
inputMode="numeric"
label={t('settings.ai.switch.echoDailyQuota')}
min={0}
onChange={(value) => updateNumber('echoDailyQuota', value)}
type="number"
value={String(props.value.echoDailyQuota ?? 200)}
/>
<TextInput
disabled={!props.value.enableEcho}
inputMode="numeric"
label={t('settings.ai.switch.echoRetrievalTopK')}
min={1}
onChange={(value) => updateNumber('echoRetrievalTopK', value)}
type="number"
value={String(props.value.echoRetrievalTopK ?? 5)}
/>
<TextInput
disabled={!props.value.enableEcho}
inputMode="decimal"
label={t('settings.ai.switch.echoRetrievalMinSimilarity')}
max={1}
min={0}
onChange={(value) =>
updateNumber('echoRetrievalMinSimilarity', value)
}
step={0.01}
type="number"
value={String(props.value.echoRetrievalMinSimilarity ?? 0.72)}
/>
<TextInput
disabled={!props.value.enableEcho}
inputMode="numeric"
label={t('settings.ai.switch.echoExemplarsCount')}
min={0}
onChange={(value) => updateNumber('echoExemplarsCount', value)}
type="number"
value={String(props.value.echoExemplarsCount ?? 4)}
/>
</NumberGrid>
<NumberGrid>
<TextInput
inputMode="numeric"
label={t('settings.ai.switch.embeddingChunkMaxTokens')}
min={64}
onChange={(value) =>
updateNestedNumber('aiEmbedding', 'chunkMaxTokens', value)
}
type="number"
value={String(props.value.aiEmbedding?.chunkMaxTokens ?? 500)}
/>
<TextInput
inputMode="numeric"
label={t('settings.ai.switch.embeddingChunkOverlapTokens')}
min={0}
onChange={(value) =>
updateNestedNumber('aiEmbedding', 'chunkOverlapTokens', value)
}
type="number"
value={String(props.value.aiEmbedding?.chunkOverlapTokens ?? 50)}
/>
<TextInput
inputMode="numeric"
label={t('settings.ai.switch.embeddingBackfillBatchSize')}
min={1}
onChange={(value) =>
updateNestedNumber('aiEmbedding', 'backfillBatchSize', value)
}
type="number"
value={String(props.value.aiEmbedding?.backfillBatchSize ?? 50)}
/>
<TextInput
inputMode="numeric"
label={t('settings.ai.switch.embeddingDefaultTopK')}
min={1}
onChange={(value) =>
updateNestedNumber('aiEmbedding', 'defaultTopK', value)
}
type="number"
value={String(props.value.aiEmbedding?.defaultTopK ?? 5)}
/>
<TextInput
inputMode="decimal"
label={t('settings.ai.switch.embeddingDefaultMinSimilarity')}
max={1}
min={0}
onChange={(value) =>
updateNestedNumber('aiEmbedding', 'defaultMinSimilarity', value)
}
step={0.01}
type="number"
value={String(
props.value.aiEmbedding?.defaultMinSimilarity ?? 0.7,
)}
/>
<TextInput
inputMode="numeric"
label={t('settings.ai.switch.memoryRecallTopK')}
min={1}
onChange={(value) =>
updateNestedNumber('aiMemory', 'recallTopK', value)
}
type="number"
value={String(props.value.aiMemory?.recallTopK ?? 5)}
/>
<TextInput
inputMode="decimal"
label={t('settings.ai.switch.memoryRecallMinSimilarity')}
max={1}
min={0}
onChange={(value) =>
updateNestedNumber('aiMemory', 'recallMinSimilarity', value)
}
step={0.01}
type="number"
value={String(props.value.aiMemory?.recallMinSimilarity ?? 0.7)}
/>
<TextInput
inputMode="numeric"
label={t('settings.ai.switch.personaDistillSampleMaxTokens')}
min={1000}
onChange={(value) =>
updateNestedNumber('aiPersona', 'distillSampleMaxTokens', value)
}
type="number"
value={String(
props.value.aiPersona?.distillSampleMaxTokens ?? 60000,
)}
/>
</NumberGrid>
</FeatureSection>

<SettingsSection
description={t('settings.ai.section.otherModelsDescription')}
title={t('settings.ai.section.otherModels')}
Expand Down Expand Up @@ -441,3 +647,11 @@ function FeatureSection(props: {
</SettingsSection>
)
}

function NumberGrid(props: { children: ReactNode }) {
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{props.children}
</div>
)
}
26 changes: 26 additions & 0 deletions apps/admin/src/features/settings/types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,47 @@ export interface AIModelAssignment {
}

export interface AIConfig {
aiEmbedding?: {
backfillBatchSize?: number
chunkMaxTokens?: number
chunkOverlapTokens?: number
defaultMinSimilarity?: number
defaultTopK?: number
}
aiMemory?: {
recallMinSimilarity?: number
recallTopK?: number
}
aiPersona?: {
distillSampleMaxTokens?: number
exemplarsCandidateCacheTtlSec?: number
exemplarsLengthMax?: number
exemplarsLengthMin?: number
}
commentReviewModel?: AIModelAssignment
echoDailyQuota?: number
echoExemplarsCount?: number
echoModel?: AIModelAssignment
echoRetrievalMinSimilarity?: number
echoRetrievalTopK?: number
enableAutoGenerateInsightsOnCreate?: boolean
enableAutoGenerateInsightsOnUpdate?: boolean
enableAutoGenerateEchoOnCreate?: boolean
enableAutoGenerateSummaryOnCreate?: boolean
enableAutoGenerateSummaryOnUpdate?: boolean
enableAutoGenerateTranslation?: boolean
enableAutoTranslateInsights?: boolean
enableEcho?: boolean
enableInsights?: boolean
enableSummary?: boolean
enableTranslation?: boolean
enableTranslationReview?: boolean
embeddingModel?: AIModelAssignment
insightsMinTextLength?: number
insightsModel?: AIModelAssignment
insightsTargetLanguages?: string[]
insightsTranslationModel?: AIModelAssignment
personaDistillModel?: AIModelAssignment
providers?: AIProviderConfig[]
summaryMinTextLength?: number
summaryModel?: AIModelAssignment
Expand Down
29 changes: 29 additions & 0 deletions apps/admin/src/i18n/resources/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1652,6 +1652,10 @@ export const enUS = {
'settings.ai.assignment.commentReviewDescription':
'Model used to review comments.',
'settings.ai.assignment.commentReviewLabel': 'Comment review',
'settings.ai.assignment.echoLabel': 'Echo generation',
'settings.ai.assignment.embeddingDescription':
'Embedding requires an explicit assignment; it does not fall back to the provider default chat model.',
'settings.ai.assignment.embeddingLabel': 'Embedding',
'settings.ai.assignment.insightsDescription':
'Model used to generate long-form insights.',
'settings.ai.assignment.insightsLabel': 'Insights',
Expand All @@ -1661,6 +1665,7 @@ export const enUS = {
'settings.ai.assignment.modelPlaceholder': 'Use provider default model',
'settings.ai.assignment.providerAriaLabel': '{label} provider',
'settings.ai.assignment.providerNone': 'Unassigned',
'settings.ai.assignment.personaDistillLabel': 'Persona distill',
'settings.ai.assignment.summaryDescription':
'Model used to generate article summaries.',
'settings.ai.assignment.summaryLabel': 'Summary',
Expand Down Expand Up @@ -1701,6 +1706,9 @@ export const enUS = {
'settings.ai.provider.editAction': 'Edit',
'settings.ai.provider.row.empty': 'No model assigned',
'settings.ai.section.featureToggles': 'Feature toggles',
'settings.ai.section.echo': 'AI echo',
'settings.ai.section.echoDescription':
'Generate persona replies for recently entries, with retrieval, memories, and persona distillation.',
'settings.ai.section.insights': 'AI insights',
'settings.ai.section.insightsDescription':
'Generate long-form insights from article content; optional translation.',
Expand All @@ -1722,7 +1730,23 @@ export const enUS = {
'Auto-generate summary on create',
'settings.ai.switch.enableAutoSummaryUpdate': 'Regenerate summary on update',
'settings.ai.switch.enableAutoTranslate': 'Auto-generate translations',
'settings.ai.switch.enableAutoEchoCreate':
'Auto-generate echo on recently create',
'settings.ai.switch.enableAutoTranslateInsights': 'Auto-translate insights',
'settings.ai.switch.echoDailyQuota': 'Echo daily quota',
'settings.ai.switch.echoExemplarsCount': 'Echo exemplars count',
'settings.ai.switch.echoRetrievalMinSimilarity':
'Echo retrieval min similarity',
'settings.ai.switch.echoRetrievalTopK': 'Echo retrieval top-K',
'settings.ai.switch.embeddingBackfillBatchSize':
'Embedding backfill batch size',
'settings.ai.switch.embeddingChunkMaxTokens': 'Embedding chunk max tokens',
'settings.ai.switch.embeddingChunkOverlapTokens':
'Embedding chunk overlap tokens',
'settings.ai.switch.embeddingDefaultMinSimilarity':
'Embedding default min similarity',
'settings.ai.switch.embeddingDefaultTopK': 'Embedding default top-K',
'settings.ai.switch.enableEcho': 'Enable AI echo',
'settings.ai.switch.enableInsights': 'Enable AI insights',
'settings.ai.switch.enableSummary': 'Enable AI summary',
'settings.ai.switch.enableTranslation': 'Enable AI translation',
Expand All @@ -1732,6 +1756,11 @@ export const enUS = {
'settings.ai.switch.insightsMinTextLength':
'Insights auto-generate minimum text length',
'settings.ai.switch.insightsTargetLanguages': 'Insights target languages',
'settings.ai.switch.memoryRecallMinSimilarity':
'Memory recall min similarity',
'settings.ai.switch.memoryRecallTopK': 'Memory recall top-K',
'settings.ai.switch.personaDistillSampleMaxTokens':
'Persona distill sample max tokens',
'settings.ai.switch.summaryMinTextLength':
'Summary auto-generate minimum text length',
'settings.ai.switch.summaryTargetLanguages': 'Summary target languages',
Expand Down
Loading
Loading