Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/admin/src/api/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,9 @@ export function getModels() {
return getModelsByCapability('text')
}

export function getModelsByCapability(capability: 'image' | 'speech' | 'text') {
export function getModelsByCapability(
capability: 'decision' | 'image' | 'speech' | 'text',
) {
return getJson<ProviderModelsResponse[]>('/ai/models', { capability })
}

Expand Down
10 changes: 10 additions & 0 deletions apps/admin/src/features/comments/components/CommentListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,16 @@ export function CommentListItem(props: CommentListItemProps) {
</span>
) : null}

{props.comment.moderationStatus === 'pending' ||
props.comment.moderationStatus === 'manual' ? (
<Badge pill size="sm" tone="warning" variant="soft">
{t(
props.comment.moderationStatus === 'manual'
? 'comments.badge.manualReview'
: 'comments.badge.reviewPending',
)}
</Badge>
) : null}
{props.comment.editedAt ? (
<Badge
data-testid="comments-row-badge-edited"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,11 @@ export function CommentsRouteViewContent() {
(comment: CommentModel) => {
// Mark as read when opening — only fires for genuinely unread rows so
// we don't spam the server when navigating among already-read items.
if (comment.state === CommentState.Unread) {
if (
comment.state === CommentState.Unread &&
comment.moderationStatus !== 'pending' &&
comment.moderationStatus !== 'manual'
) {
markReadOnOpenMutation.mutate(comment.id)
}
const qs = searchParams.toString()
Expand Down
39 changes: 36 additions & 3 deletions apps/admin/src/features/settings/components/ai/AIConfigEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ const PRESET_NAME_KEYS: Partial<Record<string, TranslationKey>> = {
xai: 'settings.ai.preset.name.xai',
}

async function getProviderModels(capability: 'image' | 'speech' | 'text') {
async function getProviderModels(
capability: 'decision' | 'image' | 'speech' | 'text',
) {
const response = await getModelsByCapability(capability)
const entries: Array<[string, AIProviderModel[]]> = response.map(
(provider) => [provider.providerId, provider.models ?? []],
Expand Down Expand Up @@ -99,6 +101,13 @@ export function AIConfigEditor(props: {
queryKey: [...props.modelCacheKey, 'image'],
staleTime: 24 * 60 * 60 * 1000,
})
const decisionModelsQuery = useQuery({
enabled: providers.some((p) => p.enabled && p.capabilities?.decision),
queryFn: () => getProviderModels('decision'),
queryKey: [...props.modelCacheKey, 'decision'],
staleTime: 24 * 60 * 60 * 1000,
})
const decisionModels = decisionModelsQuery.data ?? {}
const providerModels = modelsQuery.data ?? {}
const speechProviderModels = speechModelsQuery.data ?? {}
const imageProviderModels = imageModelsQuery.data ?? {}
Expand Down Expand Up @@ -128,6 +137,7 @@ export function AIConfigEditor(props: {

const deleteProvider = (id: string) => {
const references = [
props.value.decisionModel,
props.value.summaryModel,
props.value.writerModel,
props.value.commentReviewModel,
Expand Down Expand Up @@ -228,6 +238,18 @@ export function AIConfigEditor(props: {
)}
</SettingsSection>

<SettingsSection title={t('settings.ai.section.decision')}>
<AIModelAssignmentField
capability="decision"
label={t('settings.ai.section.decision')}
description={t('settings.ai.decision.description')}
models={decisionModels}
providers={providers}
value={props.value.decisionModel}
onChange={(decisionModel) => updateConfig({ decisionModel })}
/>
</SettingsSection>

<FeatureSection
assignment={
<AIModelAssignmentField
Expand Down Expand Up @@ -590,14 +612,22 @@ export function AIConfigEditor(props: {
</div>

<AIProviderDrawer
modelCacheKey={props.modelCacheKey}
modelCacheKey={
editingProvider?.capabilities?.decision
? [...props.modelCacheKey, 'decision']
: props.modelCacheKey
}
onChange={(patch) =>
editingId ? updateProvider(editingId, patch) : undefined
}
onClose={() => setEditingId(null)}
provider={editingProvider}
providerModels={
editingProvider ? (providerModels[editingProvider.id] ?? []) : []
editingProvider
? ((editingProvider.capabilities?.decision
? decisionModels
: providerModels)[editingProvider.id] ?? [])
: []
}
/>
</>
Expand All @@ -613,6 +643,9 @@ function ProviderRow(props: {
const { t } = useI18n()
const provider = props.provider
const capabilities = [
provider.capabilities?.decision
? t('settings.ai.capability.decision')
: null,
(provider.capabilities?.text ?? true)
? t('settings.ai.capability.text')
: null,
Expand Down
111 changes: 65 additions & 46 deletions apps/admin/src/features/settings/components/ai/AIProviderDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export function AIProviderDrawer(props: {
const [fetching, setFetching] = useState(false)
const [testing, setTesting] = useState(false)
const provider = props.provider
const decisionEnabled = provider?.type === 'typesafe'
const textEnabled = provider?.capabilities?.text ?? true
const matchedPreset = provider ? findAIProviderPreset(provider) : undefined
const isGoogleVertex = provider?.type === 'google-vertex'
Expand Down Expand Up @@ -85,7 +86,8 @@ export function AIProviderDrawer(props: {
() => mergeModelOptions(props.providerModels, registryModels),
[props.providerModels, registryModels],
)
const modelsDisabled = piProviderId === null && modelOptions.length === 0
const modelsDisabled =
!decisionEnabled && piProviderId === null && modelOptions.length === 0
const modelMatch = useMemo(
() =>
provider
Expand All @@ -94,7 +96,7 @@ export function AIProviderDrawer(props: {
[registryModels, provider],
)
const showCustomTokenFields = Boolean(
provider && provider.defaultModel.trim() && !modelMatch,
provider && !decisionEnabled && provider.defaultModel.trim() && !modelMatch,
)

const refreshModels = async () => {
Expand Down Expand Up @@ -163,7 +165,7 @@ export function AIProviderDrawer(props: {
footer={
provider ? (
<>
{textEnabled ? (
{textEnabled || decisionEnabled ? (
<>
<Button
disabled={fetching}
Expand Down Expand Up @@ -215,48 +217,56 @@ export function AIProviderDrawer(props: {
onCheckedChange={(enabled) => props.onChange({ enabled })}
/>
<div className="space-y-3 rounded-xl border border-neutral-200 p-3 dark:border-neutral-800">
<div className="text-sm font-medium text-fg">
{t('settings.ai.field.capabilities')}
</div>
<FormSwitch
checked={provider.capabilities?.text ?? true}
label={t('settings.ai.capability.text')}
onCheckedChange={(text) =>
props.onChange({
capabilities: {
text,
image: provider.capabilities?.image ?? false,
speech: provider.capabilities?.speech ?? false,
},
})
}
/>
<FormSwitch
checked={provider.capabilities?.image ?? false}
label={t('settings.ai.capability.image')}
onCheckedChange={(image) =>
props.onChange({
capabilities: {
text: provider.capabilities?.text ?? true,
image,
speech: provider.capabilities?.speech ?? false,
},
})
}
/>
<FormSwitch
checked={provider.capabilities?.speech ?? false}
label={t('settings.ai.capability.speech')}
onCheckedChange={(speech) =>
props.onChange({
capabilities: {
text: provider.capabilities?.text ?? true,
image: provider.capabilities?.image ?? false,
speech,
},
})
}
/>
{decisionEnabled ? (
<p className="text-sm text-fg">
{t('settings.ai.capability.decision')}
</p>
) : (
<>
<div className="text-sm font-medium text-fg">
{t('settings.ai.field.capabilities')}
</div>
<FormSwitch
checked={provider.capabilities?.text ?? true}
label={t('settings.ai.capability.text')}
onCheckedChange={(text) =>
props.onChange({
capabilities: {
text,
image: provider.capabilities?.image ?? false,
speech: provider.capabilities?.speech ?? false,
},
})
}
/>
<FormSwitch
checked={provider.capabilities?.image ?? false}
label={t('settings.ai.capability.image')}
onCheckedChange={(image) =>
props.onChange({
capabilities: {
text: provider.capabilities?.text ?? true,
image,
speech: provider.capabilities?.speech ?? false,
},
})
}
/>
<FormSwitch
checked={provider.capabilities?.speech ?? false}
label={t('settings.ai.capability.speech')}
onCheckedChange={(speech) =>
props.onChange({
capabilities: {
text: provider.capabilities?.text ?? true,
image: provider.capabilities?.image ?? false,
speech,
},
})
}
/>
</>
)}
</div>
<FieldShell label={t('settings.ai.field.providerType')}>
<SelectField<AIProviderType>
Expand All @@ -265,6 +275,15 @@ export function AIProviderDrawer(props: {
props.onChange({
defaultModel: getDefaultAIModel(type),
type,
capabilities: {
text: type !== 'typesafe',
decision: type === 'typesafe',
image: false,
speech: false,
},
...(type === 'typesafe'
? { endpoint: 'https://api.typesafe.ai/v1' }
: {}),
Comment on lines +284 to +286

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset the endpoint when leaving TypeSafe

When an existing TypeSafe provider is changed to any text provider through this selector, the patch changes its type and model but leaves https://api.typesafe.ai/v1 in endpoint, because an endpoint is only assigned when entering TypeSafe. Model fetching and connection tests then send the new provider protocol to the TypeSafe API and fail until the user notices and manually replaces the stale endpoint.

Useful? React with 👍 / 👎.

})
}
options={aiProviderTypeOptions.map((option) => ({
Expand Down Expand Up @@ -368,7 +387,7 @@ export function AIProviderDrawer(props: {
value={provider.voiceListUrl ?? ''}
/>
) : null}
{textEnabled ? (
{textEnabled || decisionEnabled ? (
<>
<FieldShell label={t('settings.ai.field.defaultModel')}>
<ModelCombobox
Expand Down
10 changes: 10 additions & 0 deletions apps/admin/src/features/settings/config/aiProviderPresets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ const ALL_CAPABILITIES: AIProviderConfig['capabilities'] = {
}

export const aiProviderPresets: readonly AIProviderPreset[] = [
{
id: 'typesafe',
name: 'TypeSafe / Jev',
type: 'typesafe',
endpoint: 'https://api.typesafe.ai/v1',
defaultModel: 'jev-latest',
category: 'official',
apiKeyUrl: 'https://console.typesafe.ai',
capabilities: { decision: true, text: false, image: false, speech: false },
},
{
id: 'openai',
name: 'OpenAI',
Expand Down
1 change: 1 addition & 0 deletions apps/admin/src/features/settings/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const aiProviderTypeOptions: Array<{
labelKey: TranslationKey
value: AIProviderType
}> = [
{ labelKey: 'settings.ai.providerType.typesafe', value: 'typesafe' },
{
labelKey: 'settings.ai.providerType.openaiCompatible',
value: 'openai-compatible',
Expand Down
6 changes: 4 additions & 2 deletions apps/admin/src/features/settings/types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import type { ConfigFormGroup } from '~/api/options'
import type { TranslationKey } from '~/i18n/types'

export type AIProviderType =
'anthropic' | 'generic' | 'google-vertex' | 'openai-compatible'
export type AIProviderCapability = 'image' | 'speech' | 'text'
'anthropic' | 'generic' | 'google-vertex' | 'openai-compatible' | 'typesafe'
export type AIProviderCapability = 'decision' | 'image' | 'speech' | 'text'

export interface AIProviderCapabilities {
decision?: boolean
image: boolean
speech: boolean
text: boolean
Expand Down Expand Up @@ -40,6 +41,7 @@ export interface AIModelAssignment {

export interface AIConfig {
version?: 2
decisionModel?: AIModelAssignment | null
commentReviewModel?: AIModelAssignment | null
enableAutoGenerateTranslation?: boolean
enableAutoTranslateInsights?: boolean
Expand Down
31 changes: 31 additions & 0 deletions apps/admin/src/features/settings/utils/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,3 +286,34 @@ describe('contextWindow/maxTokens visibility rule across 6 providers', () => {
})
}
})

it('preserves a saved decision provider and assignment when reopening settings', () => {
const reopened = normalizeAIConfig({
providers: [
{
id: 'jev',
name: 'Jev',
type: 'typesafe',
enabled: true,
apiKey: '',
defaultModel: 'jev-preview',
capabilities: {
decision: true,
text: false,
image: false,
speech: false,
},
},
],
decisionModel: { providerId: 'jev', model: 'jev-preview' },
})
expect(reopened.decisionModel).toEqual({
providerId: 'jev',
model: 'jev-preview',
})
expect(reopened.providers?.[0]).toMatchObject({
type: 'typesafe',
capabilities: { decision: true, text: false },
})
expect(resolvePiProviderId(reopened.providers![0])).toBeNull()
})
Loading
Loading