Skip to content
Open
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
185 changes: 156 additions & 29 deletions src/config/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { defaults } from 'lodash-es'
import Browser from 'webextension-polyfill'
import { isMobile } from '../utils/is-mobile.mjs'
import {
getApiModesFromConfig,
isInApiModeGroup,
isUsingModelName,
modelNameToDesc,
reconcileMaterializedApiModeDefaults,
} from '../utils/model-name-convert.mjs'
import { t } from 'i18next'
import {
Expand Down Expand Up @@ -657,6 +659,24 @@ for (const modelName in Models) {
/**
* @typedef {typeof defaultConfig} UserConfig
*/
export const defaultApiModeIds = [
'claude2WebFree',
'moonshotWebFree',
'ollamaModel',
'customModel',
'azureOpenAi',
'chatgptApi5_6Sol',
'chatgptApi5_6Terra',
'chatgptApi5_6Luna',
'claudeOpus48Api',
'claudeSonnet5Api',
'claudeHaiku45Api',
'googleGemini3_1Pro',
'googleGemini3_5Flash',
'openRouter_auto',
'openRouter_free',
]

export const defaultConfig = {
// general

Expand Down Expand Up @@ -732,27 +752,9 @@ export const defaultConfig = {
// others

alwaysCreateNewConversationWindow: false,
// The handling of activeApiModes and customApiModes is somewhat complex.
// It does not directly convert activeApiModes into customApiModes, which is for compatibility considerations.
// It allows the content of activeApiModes to change with version updates when the user has not customized ApiModes.
// If it were directly written into customApiModes, the value would become fixed, even if the user has not made any customizations.
activeApiModes: [
'claude2WebFree',
'moonshotWebFree',
'ollamaModel',
'customModel',
'azureOpenAi',
'chatgptApi5_6Sol',
'chatgptApi5_6Terra',
'chatgptApi5_6Luna',
'claudeOpus48Api',
'claudeSonnet5Api',
'claudeHaiku45Api',
'googleGemini3_1Pro',
'googleGemini3_5Flash',
'openRouter_auto',
'openRouter_free',
],
// Untouched profiles read this live default list. Customized profiles materialize the
// visible rows in customApiModes and use knownApiModeDefaultIds to append future defaults.
activeApiModes: [...defaultApiModeIds],
customApiModes: [
{
groupName: '',
Expand All @@ -765,9 +767,10 @@ export const defaultConfig = {
active: false,
},
],
knownApiModeDefaultIds: [],
customOpenAIProviders: [],
providerSecrets: {},
configSchemaVersion: 1,
configSchemaVersion: 2,
activeSelectionTools: ['translate', 'translateToEn', 'summary', 'polish', 'code', 'ask'],
customSelectionTools: [
{
Expand Down Expand Up @@ -944,7 +947,7 @@ export async function getPreferredLanguageKey() {
return config.preferredLanguage
}

const CONFIG_SCHEMA_VERSION = 1
const CONFIG_SCHEMA_VERSION = 2

function normalizeText(value) {
return typeof value === 'string' ? value.trim() : ''
Expand Down Expand Up @@ -1025,6 +1028,7 @@ function normalizeCustomProviderForStorage(provider, index, providerIdSet) {
function migrateUserConfig(options) {
const migrated = { ...options }
let dirty = false
const storageKeysToRemove = []

if (migrated.customChatGptWebApiUrl === 'https://chat.openai.com') {
migrated.customChatGptWebApiUrl = 'https://chatgpt.com'
Expand All @@ -1037,7 +1041,14 @@ function migrateUserConfig(options) {
dirty = true
}

const canonicalActiveApiModes = canonicalizeModelKeyArray(migrated.activeApiModes)
const activeApiModesForCanonicalization =
Array.isArray(migrated.activeApiModes) &&
migrated.activeApiModes.some((modelName) => typeof modelName !== 'string' || !modelName.trim())
? migrated.activeApiModes.filter(
(modelName) => typeof modelName === 'string' && Boolean(modelName.trim()),
)
: migrated.activeApiModes
const canonicalActiveApiModes = canonicalizeModelKeyArray(activeApiModesForCanonicalization)
if (canonicalActiveApiModes !== migrated.activeApiModes) {
migrated.activeApiModes = canonicalActiveApiModes
dirty = true
Comment thread
PeterDaveHello marked this conversation as resolved.
Expand Down Expand Up @@ -1191,7 +1202,7 @@ function migrateUserConfig(options) {
}
}

const customApiModes = Array.isArray(migrated.customApiModes)
let customApiModes = Array.isArray(migrated.customApiModes)
? migrated.customApiModes.map((apiMode) => canonicalizeApiMode({ ...apiMode }))
: []
if (!Array.isArray(migrated.customApiModes)) dirty = true
Expand Down Expand Up @@ -1555,6 +1566,101 @@ function migrateUserConfig(options) {
}
}

const currentDefaultIds = canonicalizeModelKeyArray([...defaultApiModeIds])
const migrationConfig = {
...defaultConfig,
...migrated,
customApiModes,
customOpenAIProviders,
providerSecrets,
}
const configuredCustomApiModes = getApiModesFromConfig(
{
...migrationConfig,
activeApiModes: [],
},
false,
)
const hasStoredActiveApiModes = Array.isArray(migrated.activeApiModes)
const rawKnownApiModeDefaultIds = migrated.knownApiModeDefaultIds
const hasCurrentBaseline =
Number(migrated.configSchemaVersion) >= 2 &&
Array.isArray(rawKnownApiModeDefaultIds) &&
(currentDefaultIds.length === 0 || rawKnownApiModeDefaultIds.length > 0) &&
Comment thread
PeterDaveHello marked this conversation as resolved.
rawKnownApiModeDefaultIds.every(
(modelName) => typeof modelName === 'string' && Boolean(modelName.trim()),
)
const isLiveDefaultProfile =
!hasStoredActiveApiModes && !hasCurrentBaseline && configuredCustomApiModes.length === 0

if (isLiveDefaultProfile) {
if (Object.hasOwn(options, 'activeApiModes')) {
delete migrated.activeApiModes
storageKeysToRemove.push('activeApiModes')
dirty = true
}
if (Object.hasOwn(options, 'knownApiModeDefaultIds')) {
delete migrated.knownApiModeDefaultIds
storageKeysToRemove.push('knownApiModeDefaultIds')
dirty = true
}
} else {
const activeApiModesForMaterialization = hasStoredActiveApiModes
? migrated.activeApiModes
: hasCurrentBaseline
? []
: [...currentDefaultIds]

if (!hasStoredActiveApiModes && hasCurrentBaseline) {
migrated.activeApiModes = []
dirty = true
}

if (!hasCurrentBaseline || activeApiModesForMaterialization.length > 0) {
customApiModes = getApiModesFromConfig(
{
...migrationConfig,
activeApiModes: activeApiModesForMaterialization,
customApiModes,
Comment thread
PeterDaveHello marked this conversation as resolved.
},
false,
)
migrated.activeApiModes = []
dirty = true
}
Comment thread
PeterDaveHello marked this conversation as resolved.
Comment thread
PeterDaveHello marked this conversation as resolved.

let knownApiModeDefaultIds = hasCurrentBaseline
? canonicalizeModelKeyArray(
migrated.knownApiModeDefaultIds
.filter((modelName) => typeof modelName === 'string')
.map((modelName) => modelName.trim())
.filter(Boolean),
)
Comment thread
PeterDaveHello marked this conversation as resolved.
: currentDefaultIds

if (hasCurrentBaseline) {
const reconciled = reconcileMaterializedApiModeDefaults(
{
...migrationConfig,
activeApiModes: [],
customApiModes,
},
currentDefaultIds,
knownApiModeDefaultIds,
)
customApiModes = reconciled.customApiModes
knownApiModeDefaultIds = reconciled.knownApiModeDefaultIds
if (reconciled.changed) dirty = true
}

if (
JSON.stringify(migrated.knownApiModeDefaultIds) !== JSON.stringify(knownApiModeDefaultIds)
) {
dirty = true
}
migrated.knownApiModeDefaultIds = knownApiModeDefaultIds
}

if (customProvidersDirty) dirty = true
if (customApiModesDirty) dirty = true

Expand All @@ -1581,7 +1687,7 @@ function migrateUserConfig(options) {
}
}

return { migrated, dirty }
return { migrated, dirty, storageKeysToRemove }
}

/**
Expand Down Expand Up @@ -1626,7 +1732,7 @@ export async function getUserConfig() {
}
}

const { migrated, dirty } = migrateUserConfig(options)
const { migrated, dirty, storageKeysToRemove } = migrateUserConfig(options)
if (dirty) {
const payload = {}
if (JSON.stringify(options.customApiModes) !== JSON.stringify(migrated.customApiModes)) {
Expand All @@ -1635,9 +1741,19 @@ export async function getUserConfig() {
if (options.modelName !== migrated.modelName) {
payload.modelName = migrated.modelName
}
if (JSON.stringify(options.activeApiModes) !== JSON.stringify(migrated.activeApiModes)) {
if (
Object.hasOwn(migrated, 'activeApiModes') &&
JSON.stringify(options.activeApiModes) !== JSON.stringify(migrated.activeApiModes)
) {
payload.activeApiModes = migrated.activeApiModes
}
if (
Object.hasOwn(migrated, 'knownApiModeDefaultIds') &&
JSON.stringify(options.knownApiModeDefaultIds) !==
JSON.stringify(migrated.knownApiModeDefaultIds)
) {
payload.knownApiModeDefaultIds = migrated.knownApiModeDefaultIds
}
if (
JSON.stringify(options.customOpenAIProviders) !==
JSON.stringify(migrated.customOpenAIProviders)
Expand Down Expand Up @@ -1667,8 +1783,19 @@ export async function getUserConfig() {
}
}
}
let payloadPersisted = true
if (Object.keys(payload).length > 0) {
await Browser.storage.local.set(payload).catch(() => {})
payloadPersisted = await Browser.storage.local
.set(payload)
.then(() => true)
.catch(() => false)
}
if (payloadPersisted && storageKeysToRemove.length > 0) {
try {
await Browser.storage.local.remove(storageKeysToRemove)
} catch {
// Invalid live-default sentinels remain non-authoritative and are retried on the next read.
}
}
}
return defaults(migrated, defaultConfig)
Expand Down
4 changes: 3 additions & 1 deletion src/popup/Popup.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
mergeConfigUpdate,
queueConfigWrite,
} from './popup-config-utils.mjs'
import { expandApiModeListConfigUpdate } from './api-mode-config-utils.mjs'
import { GeneralPart } from './sections/GeneralPart'
import { FeaturePages } from './sections/FeaturePages'
import { AdvancedPart } from './sections/AdvancedPart'
Expand Down Expand Up @@ -87,7 +88,8 @@ function Popup() {
// Most popup field edits are fire-and-forget. Callers that must abort
// follow-up work on persist failure opt into propagateError.
const updateConfig = async (value, options = {}) => {
const nextValue = value && typeof value === 'object' ? value : {}
const rawValue = value && typeof value === 'object' ? value : {}
const nextValue = expandApiModeListConfigUpdate(latestConfigRef.current, rawValue)
const { propagateError = false } = options && typeof options === 'object' ? options : {}
const requestId = ++updateConfigRequestIdRef.current
for (const key of Object.keys(nextValue)) {
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Expand Down
45 changes: 45 additions & 0 deletions src/popup/api-mode-config-utils.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { defaultApiModeIds } from '../config/index.mjs'
import { canonicalizeModelKeyArray } from '../config/model-key-migrations.mjs'

export const API_MODE_LIST_CONFIG_KEYS = [
'activeApiModes',
'customApiModes',
'knownApiModeDefaultIds',
]

export function buildApiModeListConfigUpdate(config, nextApiModes, { selectionPatch = {} } = {}) {
const knownApiModeDefaultIds = canonicalizeModelKeyArray([
...(Array.isArray(config?.knownApiModeDefaultIds) ? config.knownApiModeDefaultIds : []),
...defaultApiModeIds,
])

return {
...selectionPatch,
activeApiModes: [],
customApiModes: Array.isArray(nextApiModes) ? nextApiModes : [],
knownApiModeDefaultIds,
}
}

export function expandApiModeListConfigUpdate(currentConfig, value) {
const nextValue = value && typeof value === 'object' ? value : {}
if (!API_MODE_LIST_CONFIG_KEYS.some((key) => Object.hasOwn(nextValue, key))) {
return nextValue
}

const includesSelectionUpdate =
Object.hasOwn(nextValue, 'modelName') || Object.hasOwn(nextValue, 'apiMode')

return {
activeApiModes: currentConfig.activeApiModes,
customApiModes: currentConfig.customApiModes,
knownApiModeDefaultIds: currentConfig.knownApiModeDefaultIds,
...(includesSelectionUpdate
? {
modelName: currentConfig.modelName,
apiMode: currentConfig.apiMode,
}
: {}),
...nextValue,
}
Comment thread
PeterDaveHello marked this conversation as resolved.
Comment thread
PeterDaveHello marked this conversation as resolved.
}
Comment thread
PeterDaveHello marked this conversation as resolved.
Comment thread
PeterDaveHello marked this conversation as resolved.
Loading