Fix input box revert issue and configuration loss during profile switch #955

This commit is contained in:
System233 2025-02-18 00:43:38 +08:00
parent 2dcde02839
commit 2943d655cf
8 changed files with 224 additions and 163 deletions

View file

@ -33,29 +33,38 @@ export class ConfigManager {
return Math.random().toString(36).substring(2, 15)
}
// Synchronize readConfig/writeConfig operations to avoid data loss.
private _lock = Promise.resolve()
private lock<T>(cb: () => Promise<T>) {
const next = this._lock.then(cb)
this._lock = next.catch(() => {}) as Promise<void>
return next
}
/**
* Initialize config if it doesn't exist
*/
async initConfig(): Promise<void> {
try {
const config = await this.readConfig()
if (!config) {
await this.writeConfig(this.defaultConfig)
return
}
// Migrate: ensure all configs have IDs
let needsMigration = false
for (const [name, apiConfig] of Object.entries(config.apiConfigs)) {
if (!apiConfig.id) {
apiConfig.id = this.generateId()
needsMigration = true
return await this.lock(async () => {
const config = await this.readConfig()
if (!config) {
await this.writeConfig(this.defaultConfig)
return
}
}
if (needsMigration) {
await this.writeConfig(config)
}
// Migrate: ensure all configs have IDs
let needsMigration = false
for (const [name, apiConfig] of Object.entries(config.apiConfigs)) {
if (!apiConfig.id) {
apiConfig.id = this.generateId()
needsMigration = true
}
}
if (needsMigration) {
await this.writeConfig(config)
}
})
} catch (error) {
throw new Error(`Failed to initialize config: ${error}`)
}
@ -66,12 +75,14 @@ export class ConfigManager {
*/
async listConfig(): Promise<ApiConfigMeta[]> {
try {
const config = await this.readConfig()
return Object.entries(config.apiConfigs).map(([name, apiConfig]) => ({
name,
id: apiConfig.id || "",
apiProvider: apiConfig.apiProvider,
}))
return await this.lock(async () => {
const config = await this.readConfig()
return Object.entries(config.apiConfigs).map(([name, apiConfig]) => ({
name,
id: apiConfig.id || "",
apiProvider: apiConfig.apiProvider,
}))
})
} catch (error) {
throw new Error(`Failed to list configs: ${error}`)
}
@ -82,13 +93,15 @@ export class ConfigManager {
*/
async saveConfig(name: string, config: ApiConfiguration): Promise<void> {
try {
const currentConfig = await this.readConfig()
const existingConfig = currentConfig.apiConfigs[name]
currentConfig.apiConfigs[name] = {
...config,
id: existingConfig?.id || this.generateId(),
}
await this.writeConfig(currentConfig)
return await this.lock(async () => {
const currentConfig = await this.readConfig()
const existingConfig = currentConfig.apiConfigs[name]
currentConfig.apiConfigs[name] = {
...config,
id: existingConfig?.id || this.generateId(),
}
await this.writeConfig(currentConfig)
})
} catch (error) {
throw new Error(`Failed to save config: ${error}`)
}
@ -99,17 +112,19 @@ export class ConfigManager {
*/
async loadConfig(name: string): Promise<ApiConfiguration> {
try {
const config = await this.readConfig()
const apiConfig = config.apiConfigs[name]
return await this.lock(async () => {
const config = await this.readConfig()
const apiConfig = config.apiConfigs[name]
if (!apiConfig) {
throw new Error(`Config '${name}' not found`)
}
if (!apiConfig) {
throw new Error(`Config '${name}' not found`)
}
config.currentApiConfigName = name
await this.writeConfig(config)
config.currentApiConfigName = name
await this.writeConfig(config)
return apiConfig
return apiConfig
})
} catch (error) {
throw new Error(`Failed to load config: ${error}`)
}
@ -120,18 +135,20 @@ export class ConfigManager {
*/
async deleteConfig(name: string): Promise<void> {
try {
const currentConfig = await this.readConfig()
if (!currentConfig.apiConfigs[name]) {
throw new Error(`Config '${name}' not found`)
}
return await this.lock(async () => {
const currentConfig = await this.readConfig()
if (!currentConfig.apiConfigs[name]) {
throw new Error(`Config '${name}' not found`)
}
// Don't allow deleting the default config
if (Object.keys(currentConfig.apiConfigs).length === 1) {
throw new Error(`Cannot delete the last remaining configuration.`)
}
// Don't allow deleting the default config
if (Object.keys(currentConfig.apiConfigs).length === 1) {
throw new Error(`Cannot delete the last remaining configuration.`)
}
delete currentConfig.apiConfigs[name]
await this.writeConfig(currentConfig)
delete currentConfig.apiConfigs[name]
await this.writeConfig(currentConfig)
})
} catch (error) {
throw new Error(`Failed to delete config: ${error}`)
}
@ -142,13 +159,15 @@ export class ConfigManager {
*/
async setCurrentConfig(name: string): Promise<void> {
try {
const currentConfig = await this.readConfig()
if (!currentConfig.apiConfigs[name]) {
throw new Error(`Config '${name}' not found`)
}
return await this.lock(async () => {
const currentConfig = await this.readConfig()
if (!currentConfig.apiConfigs[name]) {
throw new Error(`Config '${name}' not found`)
}
currentConfig.currentApiConfigName = name
await this.writeConfig(currentConfig)
currentConfig.currentApiConfigName = name
await this.writeConfig(currentConfig)
})
} catch (error) {
throw new Error(`Failed to set current config: ${error}`)
}
@ -159,8 +178,10 @@ export class ConfigManager {
*/
async hasConfig(name: string): Promise<boolean> {
try {
const config = await this.readConfig()
return name in config.apiConfigs
return await this.lock(async () => {
const config = await this.readConfig()
return name in config.apiConfigs
})
} catch (error) {
throw new Error(`Failed to check config existence: ${error}`)
}
@ -171,12 +192,14 @@ export class ConfigManager {
*/
async setModeConfig(mode: Mode, configId: string): Promise<void> {
try {
const currentConfig = await this.readConfig()
if (!currentConfig.modeApiConfigs) {
currentConfig.modeApiConfigs = {}
}
currentConfig.modeApiConfigs[mode] = configId
await this.writeConfig(currentConfig)
return await this.lock(async () => {
const currentConfig = await this.readConfig()
if (!currentConfig.modeApiConfigs) {
currentConfig.modeApiConfigs = {}
}
currentConfig.modeApiConfigs[mode] = configId
await this.writeConfig(currentConfig)
})
} catch (error) {
throw new Error(`Failed to set mode config: ${error}`)
}
@ -187,8 +210,10 @@ export class ConfigManager {
*/
async getModeConfigId(mode: Mode): Promise<string | undefined> {
try {
const config = await this.readConfig()
return config.modeApiConfigs?.[mode]
return await this.lock(async () => {
const config = await this.readConfig()
return config.modeApiConfigs?.[mode]
})
} catch (error) {
throw new Error(`Failed to get mode config: ${error}`)
}
@ -205,7 +230,9 @@ export class ConfigManager {
* Reset all configuration by deleting the stored config from secrets
*/
public async resetAllConfigs(): Promise<void> {
await this.context.secrets.delete(this.getConfigKey())
return await this.lock(async () => {
await this.context.secrets.delete(this.getConfigKey())
})
}
private async readConfig(): Promise<ApiConfigData> {

View file

@ -1317,6 +1317,20 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
break
}
case "saveApiConfiguration":
if (message.text && message.apiConfiguration) {
try {
await this.configManager.saveConfig(message.text, message.apiConfiguration)
const listApiConfig = await this.configManager.listConfig()
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
} catch (error) {
this.outputChannel.appendLine(
`Error save api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
vscode.window.showErrorMessage("Failed to save api configuration")
}
}
break
case "upsertApiConfiguration":
if (message.text && message.apiConfiguration) {
try {
@ -1361,9 +1375,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.postStateToWebview()
} catch (error) {
this.outputChannel.appendLine(
`Error create new api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
`Error rename api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
vscode.window.showErrorMessage("Failed to create api configuration")
vscode.window.showErrorMessage("Failed to rename api configuration")
}
}
break
@ -1647,51 +1661,53 @@ export class ClineProvider implements vscode.WebviewViewProvider {
requestyModelInfo,
modelTemperature,
} = apiConfiguration
await this.updateGlobalState("apiProvider", apiProvider)
await this.updateGlobalState("apiModelId", apiModelId)
await this.storeSecret("apiKey", apiKey)
await this.updateGlobalState("glamaModelId", glamaModelId)
await this.updateGlobalState("glamaModelInfo", glamaModelInfo)
await this.storeSecret("glamaApiKey", glamaApiKey)
await this.storeSecret("openRouterApiKey", openRouterApiKey)
await this.storeSecret("awsAccessKey", awsAccessKey)
await this.storeSecret("awsSecretKey", awsSecretKey)
await this.storeSecret("awsSessionToken", awsSessionToken)
await this.updateGlobalState("awsRegion", awsRegion)
await this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference)
await this.updateGlobalState("awsProfile", awsProfile)
await this.updateGlobalState("awsUseProfile", awsUseProfile)
await this.updateGlobalState("vertexProjectId", vertexProjectId)
await this.updateGlobalState("vertexRegion", vertexRegion)
await this.updateGlobalState("openAiBaseUrl", openAiBaseUrl)
await this.storeSecret("openAiApiKey", openAiApiKey)
await this.updateGlobalState("openAiModelId", openAiModelId)
await this.updateGlobalState("openAiCustomModelInfo", openAiCustomModelInfo)
await this.updateGlobalState("openAiUseAzure", openAiUseAzure)
await this.updateGlobalState("ollamaModelId", ollamaModelId)
await this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl)
await this.updateGlobalState("lmStudioModelId", lmStudioModelId)
await this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl)
await this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl)
await this.storeSecret("geminiApiKey", geminiApiKey)
await this.storeSecret("openAiNativeApiKey", openAiNativeApiKey)
await this.storeSecret("deepSeekApiKey", deepSeekApiKey)
await this.updateGlobalState("azureApiVersion", azureApiVersion)
await this.updateGlobalState("openAiStreamingEnabled", openAiStreamingEnabled)
await this.updateGlobalState("openRouterModelId", openRouterModelId)
await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo)
await this.updateGlobalState("openRouterBaseUrl", openRouterBaseUrl)
await this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform)
await this.updateGlobalState("vsCodeLmModelSelector", vsCodeLmModelSelector)
await this.storeSecret("mistralApiKey", mistralApiKey)
await this.updateGlobalState("mistralCodestralUrl", mistralCodestralUrl)
await this.storeSecret("unboundApiKey", unboundApiKey)
await this.updateGlobalState("unboundModelId", unboundModelId)
await this.updateGlobalState("unboundModelInfo", unboundModelInfo)
await this.storeSecret("requestyApiKey", requestyApiKey)
await this.updateGlobalState("requestyModelId", requestyModelId)
await this.updateGlobalState("requestyModelInfo", requestyModelInfo)
await this.updateGlobalState("modelTemperature", modelTemperature)
await Promise.all([
this.updateGlobalState("apiProvider", apiProvider),
this.updateGlobalState("apiModelId", apiModelId),
this.storeSecret("apiKey", apiKey),
this.updateGlobalState("glamaModelId", glamaModelId),
this.updateGlobalState("glamaModelInfo", glamaModelInfo),
this.storeSecret("glamaApiKey", glamaApiKey),
this.storeSecret("openRouterApiKey", openRouterApiKey),
this.storeSecret("awsAccessKey", awsAccessKey),
this.storeSecret("awsSecretKey", awsSecretKey),
this.storeSecret("awsSessionToken", awsSessionToken),
this.updateGlobalState("awsRegion", awsRegion),
this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference),
this.updateGlobalState("awsProfile", awsProfile),
this.updateGlobalState("awsUseProfile", awsUseProfile),
this.updateGlobalState("vertexProjectId", vertexProjectId),
this.updateGlobalState("vertexRegion", vertexRegion),
this.updateGlobalState("openAiBaseUrl", openAiBaseUrl),
this.storeSecret("openAiApiKey", openAiApiKey),
this.updateGlobalState("openAiModelId", openAiModelId),
this.updateGlobalState("openAiCustomModelInfo", openAiCustomModelInfo),
this.updateGlobalState("openAiUseAzure", openAiUseAzure),
this.updateGlobalState("ollamaModelId", ollamaModelId),
this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl),
this.updateGlobalState("lmStudioModelId", lmStudioModelId),
this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl),
this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl),
this.storeSecret("geminiApiKey", geminiApiKey),
this.storeSecret("openAiNativeApiKey", openAiNativeApiKey),
this.storeSecret("deepSeekApiKey", deepSeekApiKey),
this.updateGlobalState("azureApiVersion", azureApiVersion),
this.updateGlobalState("openAiStreamingEnabled", openAiStreamingEnabled),
this.updateGlobalState("openRouterModelId", openRouterModelId),
this.updateGlobalState("openRouterModelInfo", openRouterModelInfo),
this.updateGlobalState("openRouterBaseUrl", openRouterBaseUrl),
this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform),
this.updateGlobalState("vsCodeLmModelSelector", vsCodeLmModelSelector),
this.storeSecret("mistralApiKey", mistralApiKey),
this.updateGlobalState("mistralCodestralUrl", mistralCodestralUrl),
this.storeSecret("unboundApiKey", unboundApiKey),
this.updateGlobalState("unboundModelId", unboundModelId),
this.updateGlobalState("unboundModelInfo", unboundModelInfo),
this.storeSecret("requestyApiKey", requestyApiKey),
this.updateGlobalState("requestyModelId", requestyModelId),
this.updateGlobalState("requestyModelInfo", requestyModelInfo),
this.updateGlobalState("modelTemperature", modelTemperature),
])
if (this.cline) {
this.cline.api = buildApiHandler(apiConfiguration)
}

View file

@ -1405,5 +1405,38 @@ describe("ClineProvider", () => {
])
expect(mockContext.globalState.update).toHaveBeenCalledWith("currentApiConfigName", "test-config")
})
test("handles successful saveApiConfiguration", async () => {
provider.resolveWebviewView(mockWebviewView)
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
// Mock ConfigManager methods
provider.configManager = {
saveConfig: jest.fn().mockResolvedValue(undefined),
listConfig: jest
.fn()
.mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]),
} as any
const testApiConfig = {
apiProvider: "anthropic" as const,
apiKey: "test-key",
}
// Trigger upsertApiConfiguration
await messageHandler({
type: "saveApiConfiguration",
text: "test-config",
apiConfiguration: testApiConfig,
})
// Verify config was saved
expect(provider.configManager.saveConfig).toHaveBeenCalledWith("test-config", testApiConfig)
// Verify state updates
expect(mockContext.globalState.update).toHaveBeenCalledWith("listApiConfigMeta", [
{ name: "test-config", id: "test-id", apiProvider: "anthropic" },
])
})
})
})

View file

@ -12,6 +12,7 @@ export interface WebviewMessage {
type:
| "apiConfiguration"
| "currentApiConfigName"
| "saveApiConfiguration"
| "upsertApiConfiguration"
| "deleteApiConfiguration"
| "loadApiConfiguration"

View file

@ -1,5 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useState } from "react"
import { useEvent, useInterval } from "react-use"
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useEvent } from "react-use"
import { Checkbox, Dropdown, Pane, type DropdownOption } from "vscrui"
import { VSCodeLink, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { TemperatureControl } from "./TemperatureControl"
@ -65,7 +65,8 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
return normalizeApiConfiguration(apiConfiguration)
}, [apiConfiguration])
// Poll ollama/lmstudio models
const requestLocalModelsTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// Pull ollama/lmstudio models
const requestLocalModels = useCallback(() => {
if (selectedProvider === "ollama") {
vscode.postMessage({ type: "requestOllamaModels", text: apiConfiguration?.ollamaBaseUrl })
@ -75,34 +76,29 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
vscode.postMessage({ type: "requestVsCodeLmModels" })
}
}, [selectedProvider, apiConfiguration?.ollamaBaseUrl, apiConfiguration?.lmStudioBaseUrl])
// Debounced model updates, only executed 250ms after the user stops typing
useEffect(() => {
if (selectedProvider === "ollama" || selectedProvider === "lmstudio" || selectedProvider === "vscode-lm") {
requestLocalModels()
if (requestLocalModelsTimeoutRef.current) {
clearTimeout(requestLocalModelsTimeoutRef.current)
}
}, [selectedProvider, requestLocalModels])
useInterval(
requestLocalModels,
selectedProvider === "ollama" || selectedProvider === "lmstudio" || selectedProvider === "vscode-lm"
? 2000
: null,
)
requestLocalModelsTimeoutRef.current = setTimeout(requestLocalModels, 250)
return () => {
if (requestLocalModelsTimeoutRef.current) {
clearTimeout(requestLocalModelsTimeoutRef.current)
}
}
}, [requestLocalModels])
const handleMessage = useCallback((event: MessageEvent) => {
const message: ExtensionMessage = event.data
if (message.type === "ollamaModels" && Array.isArray(message.ollamaModels)) {
const newModels = message.ollamaModels
setOllamaModels((prevModels) => {
return JSON.stringify(prevModels) === JSON.stringify(newModels) ? prevModels : newModels
})
setOllamaModels(newModels)
} else if (message.type === "lmStudioModels" && Array.isArray(message.lmStudioModels)) {
const newModels = message.lmStudioModels
setLmStudioModels((prevModels) => {
return JSON.stringify(prevModels) === JSON.stringify(newModels) ? prevModels : newModels
})
setLmStudioModels(newModels)
} else if (message.type === "vsCodeLmModels" && Array.isArray(message.vsCodeLmModels)) {
const newModels = message.vsCodeLmModels
setVsCodeLmModels((prevModels) => {
return JSON.stringify(prevModels) === JSON.stringify(newModels) ? prevModels : newModels
})
setVsCodeLmModels(newModels)
}
}, [])
useEvent("message", handleMessage)
@ -142,10 +138,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
id="api-provider"
value={selectedProvider}
onChange={(value: unknown) => {
handleInputChange(
"apiProvider",
true,
)({
handleInputChange("apiProvider")({
target: {
value: (value as DropdownOption).value,
},
@ -261,7 +254,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
value={apiConfiguration?.requestyApiKey || ""}
style={{ width: "100%" }}
type="password"
onBlur={handleInputChange("requestyApiKey")}
onInput={handleInputChange("requestyApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>Requesty API Key</span>
</VSCodeTextField>
@ -341,7 +334,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
value={apiConfiguration?.mistralCodestralUrl || ""}
style={{ width: "100%", marginTop: "10px" }}
type="url"
onBlur={handleInputChange("mistralCodestralUrl")}
onInput={handleInputChange("mistralCodestralUrl")}
placeholder="Default: https://codestral.mistral.ai">
<span style={{ fontWeight: 500 }}>Codestral Base URL (Optional)</span>
</VSCodeTextField>
@ -408,7 +401,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
value={apiConfiguration?.openRouterBaseUrl || ""}
style={{ width: "100%", marginTop: 3 }}
type="url"
onBlur={handleInputChange("openRouterBaseUrl")}
onInput={handleInputChange("openRouterBaseUrl")}
placeholder="Default: https://openrouter.ai/api/v1"
/>
)}

View file

@ -77,10 +77,6 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
setApiErrorMessage(apiValidationResult)
setModelIdErrorMessage(modelIdValidationResult)
if (!apiValidationResult && !modelIdValidationResult) {
vscode.postMessage({
type: "apiConfiguration",
apiConfiguration,
})
vscode.postMessage({ type: "alwaysAllowReadOnly", bool: alwaysAllowReadOnly })
vscode.postMessage({ type: "alwaysAllowWrite", bool: alwaysAllowWrite })
vscode.postMessage({ type: "alwaysAllowExecute", bool: alwaysAllowExecute })
@ -195,6 +191,11 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
currentApiConfigName={currentApiConfigName}
listApiConfigMeta={listApiConfigMeta}
onSelectConfig={(configName: string) => {
vscode.postMessage({
type: "saveApiConfiguration",
text: currentApiConfigName,
apiConfiguration,
})
vscode.postMessage({
type: "loadApiConfiguration",
text: configName,

View file

@ -6,7 +6,7 @@ import { vscode } from "../../utils/vscode"
import ApiOptions from "../settings/ApiOptions"
const WelcomeView = () => {
const { apiConfiguration } = useExtensionState()
const { apiConfiguration, currentApiConfigName } = useExtensionState()
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined)
@ -17,7 +17,11 @@ const WelcomeView = () => {
return
}
setErrorMessage(undefined)
vscode.postMessage({ type: "apiConfiguration", apiConfiguration })
vscode.postMessage({
type: "upsertApiConfiguration",
text: currentApiConfigName,
apiConfiguration,
})
}
return (

View file

@ -162,26 +162,12 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const handleInputChange = useCallback(
// Returns a function that handles an input change event for a specific API configuration field.
// The optional "softUpdate" flag determines whether to immediately update local state or send an external update.
(field: keyof ApiConfiguration, softUpdate?: boolean) => (event: any) => {
(field: keyof ApiConfiguration) => (event: any) => {
// Use the functional form of setState to ensure the latest state is used in the update logic.
setState((currentState) => {
if (softUpdate) {
// Return a new state object with the updated apiConfiguration.
// This will trigger a re-render with the new configuration value.
return {
...currentState,
apiConfiguration: { ...currentState.apiConfiguration, [field]: event.target.value },
}
} else {
// For non-soft updates, send a message to the VS Code extension with the updated config.
// This side effect communicates the change without updating local React state.
vscode.postMessage({
type: "upsertApiConfiguration",
text: currentState.currentApiConfigName,
apiConfiguration: { ...currentState.apiConfiguration, [field]: event.target.value },
})
// Return the unchanged state as no local state update is intended in this branch.
return currentState
return {
...currentState,
apiConfiguration: { ...currentState.apiConfiguration, [field]: event.target.value },
}
})
},