feat(acp+): config in desktop on ACP+ (#10057)
This commit is contained in:
@@ -479,6 +479,52 @@ pub struct PreferencesRemoveRequest {
|
||||
pub keys: Vec<PreferenceKey>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "_goose/unstable/config/read", response = ConfigReadResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfigReadRequest {
|
||||
pub key: String,
|
||||
#[serde(default)]
|
||||
pub is_secret: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfigReadResponse {
|
||||
#[serde(default)]
|
||||
pub value: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "_goose/unstable/config/upsert", response = EmptyResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfigUpsertRequest {
|
||||
pub key: String,
|
||||
pub value: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub is_secret: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "_goose/unstable/config/remove", response = EmptyResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfigRemoveRequest {
|
||||
pub key: String,
|
||||
#[serde(default)]
|
||||
pub is_secret: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "_goose/unstable/config/read-all", response = ConfigReadAllResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfigReadAllRequest {}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfigReadAllResponse {
|
||||
pub config: std::collections::HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PreferenceKey {
|
||||
|
||||
@@ -225,6 +225,26 @@
|
||||
"requestType": "PreferencesRemoveRequest_unstable",
|
||||
"responseType": "EmptyResponse"
|
||||
},
|
||||
{
|
||||
"method": "_goose/unstable/config/read",
|
||||
"requestType": "ConfigReadRequest_unstable",
|
||||
"responseType": "ConfigReadResponse_unstable"
|
||||
},
|
||||
{
|
||||
"method": "_goose/unstable/config/upsert",
|
||||
"requestType": "ConfigUpsertRequest_unstable",
|
||||
"responseType": "EmptyResponse"
|
||||
},
|
||||
{
|
||||
"method": "_goose/unstable/config/remove",
|
||||
"requestType": "ConfigRemoveRequest_unstable",
|
||||
"responseType": "EmptyResponse"
|
||||
},
|
||||
{
|
||||
"method": "_goose/unstable/config/read-all",
|
||||
"requestType": "ConfigReadAllRequest_unstable",
|
||||
"responseType": "ConfigReadAllResponse_unstable"
|
||||
},
|
||||
{
|
||||
"method": "_goose/unstable/defaults/read",
|
||||
"requestType": "DefaultsReadRequest_unstable",
|
||||
|
||||
@@ -2961,6 +2961,88 @@
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/unstable/preferences/remove"
|
||||
},
|
||||
"ConfigReadRequest_unstable": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"isSecret": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"key"
|
||||
],
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/unstable/config/read"
|
||||
},
|
||||
"ConfigReadResponse_unstable": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"default": null
|
||||
}
|
||||
},
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/unstable/config/read"
|
||||
},
|
||||
"ConfigUpsertRequest_unstable": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {},
|
||||
"isSecret": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"key",
|
||||
"value"
|
||||
],
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/unstable/config/upsert"
|
||||
},
|
||||
"ConfigRemoveRequest_unstable": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"isSecret": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"key"
|
||||
],
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/unstable/config/remove"
|
||||
},
|
||||
"ConfigReadAllRequest_unstable": {
|
||||
"type": "object",
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/unstable/config/read-all"
|
||||
},
|
||||
"ConfigReadAllResponse_unstable": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"config": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"config"
|
||||
],
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/unstable/config/read-all"
|
||||
},
|
||||
"DefaultsReadRequest_unstable": {
|
||||
"type": "object",
|
||||
"description": "Read Goose default provider and model configuration.",
|
||||
@@ -6043,6 +6125,42 @@
|
||||
"description": "Params for _goose/unstable/preferences/remove",
|
||||
"title": "PreferencesRemoveRequest_unstable"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ConfigReadRequest_unstable"
|
||||
}
|
||||
],
|
||||
"description": "Params for _goose/unstable/config/read",
|
||||
"title": "ConfigReadRequest_unstable"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ConfigUpsertRequest_unstable"
|
||||
}
|
||||
],
|
||||
"description": "Params for _goose/unstable/config/upsert",
|
||||
"title": "ConfigUpsertRequest_unstable"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ConfigRemoveRequest_unstable"
|
||||
}
|
||||
],
|
||||
"description": "Params for _goose/unstable/config/remove",
|
||||
"title": "ConfigRemoveRequest_unstable"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ConfigReadAllRequest_unstable"
|
||||
}
|
||||
],
|
||||
"description": "Params for _goose/unstable/config/read-all",
|
||||
"title": "ConfigReadAllRequest_unstable"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
@@ -6797,6 +6915,22 @@
|
||||
],
|
||||
"title": "PreferencesReadResponse_unstable"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ConfigReadResponse_unstable"
|
||||
}
|
||||
],
|
||||
"title": "ConfigReadResponse_unstable"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ConfigReadAllResponse_unstable"
|
||||
}
|
||||
],
|
||||
"title": "ConfigReadAllResponse_unstable"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
use super::*;
|
||||
use goose_providers::thinking::ThinkingEffort;
|
||||
|
||||
const SECRET_MASK_SHOW_LEN: usize = 8;
|
||||
|
||||
fn mask_secret(secret: serde_json::Value) -> String {
|
||||
let as_string = match secret {
|
||||
serde_json::Value::String(s) => s,
|
||||
_ => serde_json::to_string(&secret).unwrap_or_else(|_| secret.to_string()),
|
||||
};
|
||||
|
||||
let chars: Vec<_> = as_string.chars().collect();
|
||||
let show_len = std::cmp::min(chars.len() / 2, SECRET_MASK_SHOW_LEN);
|
||||
let visible: String = chars.iter().take(show_len).collect();
|
||||
let mask = "*".repeat(chars.len() - show_len);
|
||||
|
||||
format!("{}{}", visible, mask)
|
||||
}
|
||||
|
||||
impl GooseAcpAgent {
|
||||
pub(super) async fn on_preferences_read(
|
||||
&self,
|
||||
@@ -58,6 +74,101 @@ impl GooseAcpAgent {
|
||||
Ok(EmptyResponse {})
|
||||
}
|
||||
|
||||
pub(super) async fn on_config_read(
|
||||
&self,
|
||||
req: ConfigReadRequest,
|
||||
) -> Result<ConfigReadResponse, agent_client_protocol::Error> {
|
||||
let config = self.config()?;
|
||||
|
||||
if req.key == "GOOSE_PROVIDER" || req.key == "active_provider" {
|
||||
let value = config
|
||||
.get_goose_provider()
|
||||
.map(serde_json::Value::String)
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
return Ok(ConfigReadResponse { value });
|
||||
}
|
||||
if req.key == "GOOSE_MODEL" {
|
||||
let value = config
|
||||
.get_goose_model()
|
||||
.map(serde_json::Value::String)
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
return Ok(ConfigReadResponse { value });
|
||||
}
|
||||
|
||||
let value = match config.get(&req.key, req.is_secret) {
|
||||
Ok(value) if req.is_secret => serde_json::Value::String(mask_secret(value)),
|
||||
Ok(value) => value,
|
||||
Err(crate::config::ConfigError::NotFound(_)) => serde_json::Value::Null,
|
||||
Err(e) => {
|
||||
return Err(agent_client_protocol::Error::internal_error().data(e.to_string()))
|
||||
}
|
||||
};
|
||||
Ok(ConfigReadResponse { value })
|
||||
}
|
||||
|
||||
pub(super) async fn on_config_upsert(
|
||||
&self,
|
||||
req: ConfigUpsertRequest,
|
||||
) -> Result<EmptyResponse, agent_client_protocol::Error> {
|
||||
let config = self.config()?;
|
||||
|
||||
if req.key == "GOOSE_PROVIDER" {
|
||||
if let Some(name) = req.value.as_str() {
|
||||
let model = crate::config::get_provider_entry(config, name)
|
||||
.map(|e| e.model)
|
||||
.or_else(|| config.get_goose_model().ok())
|
||||
.unwrap_or_default();
|
||||
crate::config::set_active_provider(config, name, &model).internal_err()?;
|
||||
return Ok(EmptyResponse {});
|
||||
}
|
||||
}
|
||||
if req.key == "GOOSE_MODEL" {
|
||||
if let Some(model) = req.value.as_str() {
|
||||
if let Ok(provider) = config.get_goose_provider() {
|
||||
crate::config::set_active_provider(config, &provider, model).internal_err()?;
|
||||
return Ok(EmptyResponse {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config
|
||||
.set(&req.key, &req.value, req.is_secret)
|
||||
.internal_err()?;
|
||||
Ok(EmptyResponse {})
|
||||
}
|
||||
|
||||
pub(super) async fn on_config_remove(
|
||||
&self,
|
||||
req: ConfigRemoveRequest,
|
||||
) -> Result<EmptyResponse, agent_client_protocol::Error> {
|
||||
let config = self.config()?;
|
||||
|
||||
if req.is_secret {
|
||||
config.delete_secret(&req.key).internal_err()?;
|
||||
} else if req.key == "GOOSE_PROVIDER" || req.key == "active_provider" {
|
||||
config.delete("active_provider").internal_err()?;
|
||||
config.delete("GOOSE_PROVIDER").internal_err()?;
|
||||
} else if req.key == "GOOSE_MODEL" {
|
||||
if let Ok(provider) = config.get_goose_provider() {
|
||||
crate::config::set_active_provider(config, &provider, "").internal_err()?;
|
||||
}
|
||||
config.delete("GOOSE_MODEL").internal_err()?;
|
||||
} else {
|
||||
config.delete(&req.key).internal_err()?;
|
||||
}
|
||||
|
||||
Ok(EmptyResponse {})
|
||||
}
|
||||
|
||||
pub(super) async fn on_config_read_all(
|
||||
&self,
|
||||
_req: ConfigReadAllRequest,
|
||||
) -> Result<ConfigReadAllResponse, agent_client_protocol::Error> {
|
||||
let config = self.config()?;
|
||||
let values = config.all_values().internal_err()?;
|
||||
Ok(ConfigReadAllResponse { config: values })
|
||||
}
|
||||
|
||||
pub(super) async fn on_defaults_read(
|
||||
&self,
|
||||
_req: DefaultsReadRequest,
|
||||
|
||||
@@ -376,6 +376,38 @@ impl GooseAcpAgent {
|
||||
self.on_preferences_remove(req).await
|
||||
}
|
||||
|
||||
#[custom_method(ConfigReadRequest)]
|
||||
async fn dispatch_config_read(
|
||||
&self,
|
||||
req: ConfigReadRequest,
|
||||
) -> Result<ConfigReadResponse, agent_client_protocol::Error> {
|
||||
self.on_config_read(req).await
|
||||
}
|
||||
|
||||
#[custom_method(ConfigUpsertRequest)]
|
||||
async fn dispatch_config_upsert(
|
||||
&self,
|
||||
req: ConfigUpsertRequest,
|
||||
) -> Result<EmptyResponse, agent_client_protocol::Error> {
|
||||
self.on_config_upsert(req).await
|
||||
}
|
||||
|
||||
#[custom_method(ConfigRemoveRequest)]
|
||||
async fn dispatch_config_remove(
|
||||
&self,
|
||||
req: ConfigRemoveRequest,
|
||||
) -> Result<EmptyResponse, agent_client_protocol::Error> {
|
||||
self.on_config_remove(req).await
|
||||
}
|
||||
|
||||
#[custom_method(ConfigReadAllRequest)]
|
||||
async fn dispatch_config_read_all(
|
||||
&self,
|
||||
req: ConfigReadAllRequest,
|
||||
) -> Result<ConfigReadAllResponse, agent_client_protocol::Error> {
|
||||
self.on_config_read_all(req).await
|
||||
}
|
||||
|
||||
#[custom_method(DefaultsReadRequest)]
|
||||
async fn dispatch_defaults_read(
|
||||
&self,
|
||||
|
||||
@@ -46,7 +46,6 @@ vi.mock('./api', () => {
|
||||
|
||||
return {
|
||||
initConfig: vi.fn().mockResolvedValue(undefined),
|
||||
readAllConfig: vi.fn().mockResolvedValue(undefined),
|
||||
backupConfig: vi.fn().mockResolvedValue(undefined),
|
||||
recoverConfig: vi.fn().mockResolvedValue(undefined),
|
||||
validateConfig: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { getAcpClient } from './acpConnection';
|
||||
|
||||
export type ConfigReadValue = unknown;
|
||||
|
||||
export async function acpReadConfig(
|
||||
key: string,
|
||||
isSecret: boolean = false
|
||||
): Promise<ConfigReadValue> {
|
||||
const client = await getAcpClient();
|
||||
const { value } = await client.goose.configRead_unstable({ key, isSecret });
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (isSecret) {
|
||||
return { maskedValue: value as string };
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function acpUpsertConfig(
|
||||
key: string,
|
||||
value: unknown,
|
||||
isSecret: boolean = false
|
||||
): Promise<void> {
|
||||
const client = await getAcpClient();
|
||||
await client.goose.configUpsert_unstable({ key, value, isSecret });
|
||||
}
|
||||
|
||||
export async function acpRemoveConfig(key: string, isSecret: boolean): Promise<void> {
|
||||
const client = await getAcpClient();
|
||||
await client.goose.configRemove_unstable({ key, isSecret });
|
||||
}
|
||||
|
||||
export async function acpReadAllConfig(): Promise<Record<string, unknown>> {
|
||||
const client = await getAcpClient();
|
||||
const { config } = await client.goose.configReadAll_unstable({});
|
||||
return config;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { readAllConfig, readConfig, removeConfig, upsertConfig } from '../api';
|
||||
import { acpReadAllConfig, acpReadConfig, acpRemoveConfig, acpUpsertConfig } from '../acp/config';
|
||||
import { acpListProviderDetails } from '../acp/providers';
|
||||
import {
|
||||
getConfiguredExtensions,
|
||||
@@ -9,13 +9,7 @@ import {
|
||||
} from '../acp/extensions';
|
||||
import { pruneDeprecatedBundledExtensions, syncBundledExtensions } from './settings/extensions';
|
||||
import { nameToKey } from './settings/extensions/utils';
|
||||
import type {
|
||||
ConfigResponse,
|
||||
UpsertConfigQuery,
|
||||
ConfigKeyQuery,
|
||||
ProviderDetails,
|
||||
ExtensionConfig,
|
||||
} from '../api';
|
||||
import type { ConfigResponse, ProviderDetails, ExtensionConfig } from '../api';
|
||||
|
||||
export type { ExtensionConfig } from '../api/types.gen';
|
||||
|
||||
@@ -57,20 +51,13 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
||||
providersListRef.current = providersList;
|
||||
|
||||
const reloadConfig = useCallback(async () => {
|
||||
const response = await readAllConfig();
|
||||
setConfig(response.data?.config || {});
|
||||
const config = await acpReadAllConfig();
|
||||
setConfig(config);
|
||||
}, []);
|
||||
|
||||
const upsert = useCallback(
|
||||
async (key: string, value: unknown, isSecret: boolean = false) => {
|
||||
const query: UpsertConfigQuery = {
|
||||
key: key,
|
||||
value: value,
|
||||
is_secret: isSecret,
|
||||
};
|
||||
await upsertConfig({
|
||||
body: query,
|
||||
});
|
||||
await acpUpsertConfig(key, value, isSecret);
|
||||
await reloadConfig();
|
||||
},
|
||||
[reloadConfig]
|
||||
@@ -78,24 +65,21 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
||||
|
||||
const read = useCallback(
|
||||
async (key: string, is_secret: boolean = false, options?: { throwOnError?: boolean }) => {
|
||||
const query: ConfigKeyQuery = { key: key, is_secret: is_secret };
|
||||
const response = await readConfig({
|
||||
body: query,
|
||||
});
|
||||
if (options?.throwOnError && response.error) {
|
||||
throw response.error;
|
||||
try {
|
||||
return await acpReadConfig(key, is_secret);
|
||||
} catch (error) {
|
||||
if (options?.throwOnError) {
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (key: string, is_secret: boolean) => {
|
||||
const query: ConfigKeyQuery = { key: key, is_secret: is_secret };
|
||||
await removeConfig({
|
||||
body: query,
|
||||
});
|
||||
await acpRemoveConfig(key, is_secret);
|
||||
await reloadConfig();
|
||||
},
|
||||
[reloadConfig]
|
||||
@@ -167,8 +151,8 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
||||
// Load all configuration data and providers on mount
|
||||
(async () => {
|
||||
// Load config
|
||||
const configResponse = await readAllConfig();
|
||||
setConfig(configResponse.data?.config || {});
|
||||
const configResponse = await acpReadAllConfig();
|
||||
setConfig(configResponse);
|
||||
|
||||
// Load providers
|
||||
try {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { FaPencilAlt, FaSave } from 'react-icons/fa';
|
||||
import { cn } from '../../utils';
|
||||
import { errorMessage } from '../../utils/conversionUtils';
|
||||
import { Alert, AlertType } from './types';
|
||||
import { upsertConfig } from '../../api';
|
||||
import { useConfig } from '../ConfigContext';
|
||||
import { defineMessages, useIntl } from '../../i18n';
|
||||
|
||||
@@ -43,7 +42,7 @@ const alertStyles: Record<AlertType, string> = {
|
||||
|
||||
export const AlertBox = ({ alert, className }: AlertBoxProps) => {
|
||||
const intl = useIntl();
|
||||
const { read } = useConfig();
|
||||
const { read, upsert } = useConfig();
|
||||
const [isEditingThreshold, setIsEditingThreshold] = useState(false);
|
||||
const [loadedThreshold, setLoadedThreshold] = useState<number>(0.8);
|
||||
const [thresholdValue, setThresholdValue] = useState(80);
|
||||
@@ -79,13 +78,7 @@ export const AlertBox = ({ alert, className }: AlertBoxProps) => {
|
||||
try {
|
||||
const newThreshold = validThreshold / 100; // Convert percentage to decimal
|
||||
|
||||
await upsertConfig({
|
||||
body: {
|
||||
key: 'GOOSE_AUTO_COMPACT_THRESHOLD',
|
||||
value: newThreshold,
|
||||
is_secret: false,
|
||||
},
|
||||
});
|
||||
await upsert('GOOSE_AUTO_COMPACT_THRESHOLD', newThreshold, false);
|
||||
|
||||
setIsEditingThreshold(false);
|
||||
setLoadedThreshold(newThreshold);
|
||||
|
||||
@@ -4,17 +4,18 @@ import userEvent from '@testing-library/user-event';
|
||||
import ExtensionModal from './ExtensionModal';
|
||||
import { ExtensionFormData } from '../utils';
|
||||
import { IntlTestWrapper } from '../../../../i18n/test-utils';
|
||||
import { upsertConfig } from '../../../../api';
|
||||
import { acpUpsertConfig } from '../../../../acp/config';
|
||||
|
||||
vi.mock('../../../../api', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../../api')>('../../../../api');
|
||||
vi.mock('../../../../acp/config', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../../../../acp/config')>('../../../../acp/config');
|
||||
return {
|
||||
...actual,
|
||||
upsertConfig: vi.fn().mockResolvedValue({ data: 'ok' }),
|
||||
acpUpsertConfig: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedUpsertConfig = vi.mocked(upsertConfig);
|
||||
const mockedUpsertConfig = vi.mocked(acpUpsertConfig);
|
||||
|
||||
const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) =>
|
||||
render(ui, { wrapper: IntlTestWrapper, ...options });
|
||||
@@ -261,12 +262,7 @@ describe('ExtensionModal', () => {
|
||||
describe('pending env var capture (fix for #8969)', () => {
|
||||
beforeEach(() => {
|
||||
mockedUpsertConfig.mockClear();
|
||||
mockedUpsertConfig.mockResolvedValue({
|
||||
data: 'ok',
|
||||
error: undefined,
|
||||
request: new globalThis.Request('http://localhost/test'),
|
||||
response: new globalThis.Response(),
|
||||
});
|
||||
mockedUpsertConfig.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
const emptyInitialData: ExtensionFormData = {
|
||||
@@ -330,15 +326,7 @@ describe('ExtensionModal', () => {
|
||||
expect(mockOnSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockedUpsertConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
is_secret: true,
|
||||
key: 'JWT_TOKEN',
|
||||
value: 'my_very_long_token',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockedUpsertConfig).toHaveBeenCalledWith('JWT_TOKEN', 'my_very_long_token', true);
|
||||
|
||||
const submittedData = mockOnSubmit.mock.calls[0][0];
|
||||
expect(submittedData.envVars).toEqual(
|
||||
@@ -382,9 +370,9 @@ describe('ExtensionModal', () => {
|
||||
});
|
||||
|
||||
expect(mockedUpsertConfig).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({ key: 'LONELY_KEY' }),
|
||||
})
|
||||
'LONELY_KEY',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
|
||||
const submittedData = mockOnSubmit.mock.calls[0][0];
|
||||
|
||||
@@ -15,7 +15,7 @@ import ExtensionConfigFields from './ExtensionConfigFields';
|
||||
import { PlusIcon, Edit, Trash2, AlertTriangle, Info } from 'lucide-react';
|
||||
import ExtensionInfoFields from './ExtensionInfoFields';
|
||||
import ExtensionTimeoutField from './ExtensionTimeoutField';
|
||||
import { upsertConfig } from '../../../../api';
|
||||
import { acpUpsertConfig } from '../../../../acp/config';
|
||||
import { ConfirmationModal } from '../../../ui/ConfirmationModal';
|
||||
import { defineMessages, useIntl } from '../../../../i18n';
|
||||
|
||||
@@ -244,13 +244,7 @@ export default function ExtensionModal({
|
||||
// Function to store a secret value
|
||||
const storeSecret = async (key: string, value: string) => {
|
||||
try {
|
||||
await upsertConfig({
|
||||
body: {
|
||||
is_secret: true,
|
||||
key: key,
|
||||
value: value,
|
||||
},
|
||||
});
|
||||
await acpUpsertConfig(key, value, true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to store secret:', error);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import SuspenseLoader from './suspense-loader';
|
||||
import { client } from './api/client.gen';
|
||||
import { setTelemetryEnabled } from './utils/analytics';
|
||||
import { readConfig } from './api';
|
||||
import { acpReadConfig } from './acp/config';
|
||||
import { applyThemeTokens } from './theme/theme-tokens';
|
||||
import { currentLocale, currentMessageLocale, loadMessages } from './i18n';
|
||||
|
||||
@@ -50,10 +50,8 @@ function handleIntlError(err: { code: string; message?: string }) {
|
||||
});
|
||||
|
||||
try {
|
||||
const telemetryResponse = await readConfig({
|
||||
body: { key: TELEMETRY_CONFIG_KEY, is_secret: false },
|
||||
});
|
||||
const isTelemetryEnabled = telemetryResponse.data !== false;
|
||||
const telemetryValue = await acpReadConfig(TELEMETRY_CONFIG_KEY, false);
|
||||
const isTelemetryEnabled = telemetryValue !== false;
|
||||
setTelemetryEnabled(isTelemetryEnabled);
|
||||
} catch (error) {
|
||||
console.warn('[Analytics] Failed to initialize analytics:', error);
|
||||
|
||||
@@ -20,6 +20,12 @@ import type {
|
||||
ArchiveSessionRequest_unstable,
|
||||
CanonicalModelInfoRequest_unstable,
|
||||
CanonicalModelInfoResponse_unstable,
|
||||
ConfigReadAllRequest_unstable,
|
||||
ConfigReadAllResponse_unstable,
|
||||
ConfigReadRequest_unstable,
|
||||
ConfigReadResponse_unstable,
|
||||
ConfigRemoveRequest_unstable,
|
||||
ConfigUpsertRequest_unstable,
|
||||
CreateScheduleRequest_unstable,
|
||||
CreateScheduleResponse_unstable,
|
||||
CreateSourceRequest_unstable,
|
||||
@@ -178,6 +184,8 @@ import {
|
||||
zAppsImportResponse_unstable,
|
||||
zAppsListResponse_unstable,
|
||||
zCanonicalModelInfoResponse_unstable,
|
||||
zConfigReadAllResponse_unstable,
|
||||
zConfigReadResponse_unstable,
|
||||
zCreateScheduleResponse_unstable,
|
||||
zCreateSourceResponse_unstable,
|
||||
zCustomProviderCreateResponse_unstable,
|
||||
@@ -719,6 +727,42 @@ export class GooseExtClient {
|
||||
await this.conn.extMethod("_goose/unstable/preferences/remove", params);
|
||||
}
|
||||
|
||||
async configRead_unstable(
|
||||
params: ConfigReadRequest_unstable,
|
||||
): Promise<ConfigReadResponse_unstable> {
|
||||
const raw = await this.conn.extMethod(
|
||||
"_goose/unstable/config/read",
|
||||
params,
|
||||
);
|
||||
return zConfigReadResponse_unstable.parse(
|
||||
raw,
|
||||
) as ConfigReadResponse_unstable;
|
||||
}
|
||||
|
||||
async configUpsert_unstable(
|
||||
params: ConfigUpsertRequest_unstable,
|
||||
): Promise<void> {
|
||||
await this.conn.extMethod("_goose/unstable/config/upsert", params);
|
||||
}
|
||||
|
||||
async configRemove_unstable(
|
||||
params: ConfigRemoveRequest_unstable,
|
||||
): Promise<void> {
|
||||
await this.conn.extMethod("_goose/unstable/config/remove", params);
|
||||
}
|
||||
|
||||
async configReadAll_unstable(
|
||||
params: ConfigReadAllRequest_unstable,
|
||||
): Promise<ConfigReadAllResponse_unstable> {
|
||||
const raw = await this.conn.extMethod(
|
||||
"_goose/unstable/config/read-all",
|
||||
params,
|
||||
);
|
||||
return zConfigReadAllResponse_unstable.parse(
|
||||
raw,
|
||||
) as ConfigReadAllResponse_unstable;
|
||||
}
|
||||
|
||||
async defaultsRead_unstable(
|
||||
params: DefaultsReadRequest_unstable,
|
||||
): Promise<DefaultsReadResponse_unstable> {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1218,6 +1218,36 @@ export type PreferencesRemoveRequest_unstable = {
|
||||
keys?: Array<PreferenceKey>;
|
||||
};
|
||||
|
||||
export type ConfigReadRequest_unstable = {
|
||||
key: string;
|
||||
isSecret?: boolean;
|
||||
};
|
||||
|
||||
export type ConfigReadResponse_unstable = {
|
||||
value?: unknown;
|
||||
};
|
||||
|
||||
export type ConfigUpsertRequest_unstable = {
|
||||
key: string;
|
||||
value: unknown;
|
||||
isSecret?: boolean;
|
||||
};
|
||||
|
||||
export type ConfigRemoveRequest_unstable = {
|
||||
key: string;
|
||||
isSecret?: boolean;
|
||||
};
|
||||
|
||||
export type ConfigReadAllRequest_unstable = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ConfigReadAllResponse_unstable = {
|
||||
config: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Read Goose default provider and model configuration.
|
||||
*/
|
||||
@@ -2195,14 +2225,14 @@ export type RecipeParamsAction = 'submit' | 'cancel';
|
||||
export type ExtRequest = {
|
||||
id: string;
|
||||
method: string;
|
||||
params?: AddSessionExtensionRequest_unstable | RemoveSessionExtensionRequest_unstable | GetToolsRequest_unstable | SetToolPermissionsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | AppsListRequest_unstable | AppsExportRequest_unstable | AppsImportRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | SteerSessionRequest_unstable | DiagnosticsGetRequest_unstable | ListPromptsRequest_unstable | GetPromptRequest_unstable | SavePromptRequest_unstable | ResetPromptRequest_unstable | DeleteSessionRequest | GetConfigExtensionsRequest_unstable | GetAvailableExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | SetConfigExtensionEnabledRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | ProviderSecretsListRequest_unstable | ProviderSecretDeleteRequest_unstable | CanonicalModelInfoRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | DefaultsClearRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | ShareSessionNostrRequest_unstable | EncodeRecipeRequest_unstable | DecodeRecipeRequest_unstable | ScanRecipeRequest_unstable | ListRecipesRequest_unstable | DeleteRecipeRequest_unstable | ScheduleRecipeRequest_unstable | SetRecipeSlashCommandRequest_unstable | SaveRecipeRequest_unstable | ParseRecipeRequest_unstable | RecipeToYamlRequest_unstable | ListSchedulesRequest_unstable | ListScheduleSessionsRequest_unstable | CreateScheduleRequest_unstable | DeleteScheduleRequest_unstable | PauseScheduleRequest_unstable | UnpauseScheduleRequest_unstable | UpdateScheduleRequest_unstable | RunScheduleNowRequest_unstable | KillRunningJobRequest_unstable | InspectRunningJobRequest_unstable | GetSessionInfoRequest_unstable | TruncateSessionConversationRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | ListAgentMentionsRequest_unstable | ListSlashCommandsRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | {
|
||||
params?: AddSessionExtensionRequest_unstable | RemoveSessionExtensionRequest_unstable | GetToolsRequest_unstable | SetToolPermissionsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | AppsListRequest_unstable | AppsExportRequest_unstable | AppsImportRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | SteerSessionRequest_unstable | DiagnosticsGetRequest_unstable | ListPromptsRequest_unstable | GetPromptRequest_unstable | SavePromptRequest_unstable | ResetPromptRequest_unstable | DeleteSessionRequest | GetConfigExtensionsRequest_unstable | GetAvailableExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | SetConfigExtensionEnabledRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | ProviderSecretsListRequest_unstable | ProviderSecretDeleteRequest_unstable | CanonicalModelInfoRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | ConfigReadRequest_unstable | ConfigUpsertRequest_unstable | ConfigRemoveRequest_unstable | ConfigReadAllRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | DefaultsClearRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | ShareSessionNostrRequest_unstable | EncodeRecipeRequest_unstable | DecodeRecipeRequest_unstable | ScanRecipeRequest_unstable | ListRecipesRequest_unstable | DeleteRecipeRequest_unstable | ScheduleRecipeRequest_unstable | SetRecipeSlashCommandRequest_unstable | SaveRecipeRequest_unstable | ParseRecipeRequest_unstable | RecipeToYamlRequest_unstable | ListSchedulesRequest_unstable | ListScheduleSessionsRequest_unstable | CreateScheduleRequest_unstable | DeleteScheduleRequest_unstable | PauseScheduleRequest_unstable | UnpauseScheduleRequest_unstable | UpdateScheduleRequest_unstable | RunScheduleNowRequest_unstable | KillRunningJobRequest_unstable | InspectRunningJobRequest_unstable | GetSessionInfoRequest_unstable | TruncateSessionConversationRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | ListAgentMentionsRequest_unstable | ListSlashCommandsRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ExtResponse = {
|
||||
id: string;
|
||||
result?: EmptyResponse | GetToolsResponse_unstable | SetToolPermissionsResponse_unstable | GooseToolCallResponse_unstable | ReadResourceResponse_unstable | AppsListResponse_unstable | AppsExportResponse_unstable | AppsImportResponse_unstable | SteerSessionResponse_unstable | DiagnosticsGetResponse_unstable | ListPromptsResponse_unstable | GetPromptResponse_unstable | PromptOperationResponse_unstable | GetConfigExtensionsResponse_unstable | GetAvailableExtensionsResponse_unstable | GetSessionExtensionsResponse_unstable | ListProvidersResponse_unstable | ProviderSupportedModelsListResponse_unstable | ProviderCatalogListResponse_unstable | ProviderSetupCatalogListResponse_unstable | ProviderCatalogTemplateResponse_unstable | CustomProviderCreateResponse_unstable | CustomProviderReadResponse_unstable | CustomProviderUpdateResponse_unstable | CustomProviderDeleteResponse_unstable | RefreshProviderInventoryResponse_unstable | ProviderConfigReadResponse_unstable | ProviderConfigStatusResponse_unstable | ProviderConfigChangeResponse_unstable | ProviderSecretsListResponse_unstable | CanonicalModelInfoResponse_unstable | PreferencesReadResponse_unstable | DefaultsReadResponse_unstable | OnboardingImportScanResponse_unstable | OnboardingImportApplyResponse_unstable | ExportSessionResponse_unstable | ImportSessionResponse_unstable | ShareSessionNostrResponse_unstable | EncodeRecipeResponse_unstable | DecodeRecipeResponse_unstable | ScanRecipeResponse_unstable | ListRecipesResponse_unstable | SaveRecipeResponse_unstable | ParseRecipeResponse_unstable | RecipeToYamlResponse_unstable | ListSchedulesResponse_unstable | ListScheduleSessionsResponse_unstable | CreateScheduleResponse_unstable | UpdateScheduleResponse_unstable | RunScheduleNowResponse_unstable | KillRunningJobResponse_unstable | InspectRunningJobResponse_unstable | GetSessionInfoResponse_unstable | CreateSourceResponse_unstable | ListSourcesResponse_unstable | ListAgentMentionsResponse_unstable | ListSlashCommandsResponse_unstable | UpdateSourceResponse_unstable | ExportSourceResponse_unstable | ImportSourcesResponse_unstable | DictationTranscribeResponse_unstable | DictationConfigResponse_unstable | DictationModelsListResponse_unstable | DictationModelDownloadProgressResponse_unstable | unknown;
|
||||
result?: EmptyResponse | GetToolsResponse_unstable | SetToolPermissionsResponse_unstable | GooseToolCallResponse_unstable | ReadResourceResponse_unstable | AppsListResponse_unstable | AppsExportResponse_unstable | AppsImportResponse_unstable | SteerSessionResponse_unstable | DiagnosticsGetResponse_unstable | ListPromptsResponse_unstable | GetPromptResponse_unstable | PromptOperationResponse_unstable | GetConfigExtensionsResponse_unstable | GetAvailableExtensionsResponse_unstable | GetSessionExtensionsResponse_unstable | ListProvidersResponse_unstable | ProviderSupportedModelsListResponse_unstable | ProviderCatalogListResponse_unstable | ProviderSetupCatalogListResponse_unstable | ProviderCatalogTemplateResponse_unstable | CustomProviderCreateResponse_unstable | CustomProviderReadResponse_unstable | CustomProviderUpdateResponse_unstable | CustomProviderDeleteResponse_unstable | RefreshProviderInventoryResponse_unstable | ProviderConfigReadResponse_unstable | ProviderConfigStatusResponse_unstable | ProviderConfigChangeResponse_unstable | ProviderSecretsListResponse_unstable | CanonicalModelInfoResponse_unstable | PreferencesReadResponse_unstable | ConfigReadResponse_unstable | ConfigReadAllResponse_unstable | DefaultsReadResponse_unstable | OnboardingImportScanResponse_unstable | OnboardingImportApplyResponse_unstable | ExportSessionResponse_unstable | ImportSessionResponse_unstable | ShareSessionNostrResponse_unstable | EncodeRecipeResponse_unstable | DecodeRecipeResponse_unstable | ScanRecipeResponse_unstable | ListRecipesResponse_unstable | SaveRecipeResponse_unstable | ParseRecipeResponse_unstable | RecipeToYamlResponse_unstable | ListSchedulesResponse_unstable | ListScheduleSessionsResponse_unstable | CreateScheduleResponse_unstable | UpdateScheduleResponse_unstable | RunScheduleNowResponse_unstable | KillRunningJobResponse_unstable | InspectRunningJobResponse_unstable | GetSessionInfoResponse_unstable | CreateSourceResponse_unstable | ListSourcesResponse_unstable | ListAgentMentionsResponse_unstable | ListSlashCommandsResponse_unstable | UpdateSourceResponse_unstable | ExportSourceResponse_unstable | ImportSourcesResponse_unstable | DictationTranscribeResponse_unstable | DictationConfigResponse_unstable | DictationModelsListResponse_unstable | DictationModelDownloadProgressResponse_unstable | unknown;
|
||||
} | {
|
||||
error: {
|
||||
code: number;
|
||||
|
||||
@@ -1243,6 +1243,32 @@ export const zPreferencesRemoveRequest_unstable = z.object({
|
||||
keys: z.array(zPreferenceKey).optional().default([])
|
||||
});
|
||||
|
||||
export const zConfigReadRequest_unstable = z.object({
|
||||
key: z.string(),
|
||||
isSecret: z.boolean().optional().default(false)
|
||||
});
|
||||
|
||||
export const zConfigReadResponse_unstable = z.object({
|
||||
value: z.unknown().optional().default(null)
|
||||
});
|
||||
|
||||
export const zConfigUpsertRequest_unstable = z.object({
|
||||
key: z.string(),
|
||||
value: z.unknown(),
|
||||
isSecret: z.boolean().optional().default(false)
|
||||
});
|
||||
|
||||
export const zConfigRemoveRequest_unstable = z.object({
|
||||
key: z.string(),
|
||||
isSecret: z.boolean().optional().default(false)
|
||||
});
|
||||
|
||||
export const zConfigReadAllRequest_unstable = z.record(z.unknown());
|
||||
|
||||
export const zConfigReadAllResponse_unstable = z.object({
|
||||
config: z.record(z.unknown())
|
||||
});
|
||||
|
||||
/**
|
||||
* Read Goose default provider and model configuration.
|
||||
*/
|
||||
@@ -2400,6 +2426,10 @@ export const zExtRequest = z.object({
|
||||
zPreferencesReadRequest_unstable,
|
||||
zPreferencesSaveRequest_unstable,
|
||||
zPreferencesRemoveRequest_unstable,
|
||||
zConfigReadRequest_unstable,
|
||||
zConfigUpsertRequest_unstable,
|
||||
zConfigRemoveRequest_unstable,
|
||||
zConfigReadAllRequest_unstable,
|
||||
zDefaultsReadRequest_unstable,
|
||||
zDefaultsSaveRequest_unstable,
|
||||
zDefaultsClearRequest_unstable,
|
||||
@@ -2497,6 +2527,8 @@ export const zExtResponse = z.union([
|
||||
zProviderSecretsListResponse_unstable,
|
||||
zCanonicalModelInfoResponse_unstable,
|
||||
zPreferencesReadResponse_unstable,
|
||||
zConfigReadResponse_unstable,
|
||||
zConfigReadAllResponse_unstable,
|
||||
zDefaultsReadResponse_unstable,
|
||||
zOnboardingImportScanResponse_unstable,
|
||||
zOnboardingImportApplyResponse_unstable,
|
||||
|
||||
Reference in New Issue
Block a user