feat: Implement LLM provider deletion and refactor ConfigManager to preserve unknown payload sections.
This commit is contained in:
21
.gitattributes
vendored
Normal file
21
.gitattributes
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
*.ps1 text eol=crlf
|
||||||
|
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.pdf binary
|
||||||
|
*.zip binary
|
||||||
|
*.gz binary
|
||||||
|
*.tar binary
|
||||||
|
*.db binary
|
||||||
|
*.sqlite binary
|
||||||
|
*.p12 binary
|
||||||
|
*.pfx binary
|
||||||
|
*.crt binary
|
||||||
|
*.pem binary
|
||||||
53
backend/src/core/__tests__/test_config_manager_compat.py
Normal file
53
backend/src/core/__tests__/test_config_manager_compat.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# [DEF:backend.src.core.__tests__.test_config_manager_compat:Module]
|
||||||
|
# @TIER: STANDARD
|
||||||
|
# @SEMANTICS: config-manager, compatibility, payload, tests
|
||||||
|
# @PURPOSE: Verifies ConfigManager compatibility wrappers preserve legacy payload sections.
|
||||||
|
# @LAYER: Domain
|
||||||
|
# @RELATION: VERIFIES -> ConfigManager
|
||||||
|
|
||||||
|
from src.core.config_manager import ConfigManager
|
||||||
|
from src.core.config_models import AppConfig, GlobalSettings
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_get_payload_preserves_legacy_sections:Function]
|
||||||
|
# @PURPOSE: Ensure get_payload merges typed config into raw payload without dropping legacy sections.
|
||||||
|
def test_get_payload_preserves_legacy_sections():
|
||||||
|
manager = ConfigManager.__new__(ConfigManager)
|
||||||
|
manager.raw_payload = {"notifications": {"smtp": {"host": "mail.local"}}}
|
||||||
|
manager.config = AppConfig(environments=[], settings=GlobalSettings())
|
||||||
|
|
||||||
|
payload = manager.get_payload()
|
||||||
|
|
||||||
|
assert payload["settings"]["migration_sync_cron"] == "0 2 * * *"
|
||||||
|
assert payload["notifications"]["smtp"]["host"] == "mail.local"
|
||||||
|
# [/DEF:test_get_payload_preserves_legacy_sections:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_save_config_accepts_raw_payload_and_keeps_extras:Function]
|
||||||
|
# @PURPOSE: Ensure save_config accepts raw dict payload, refreshes typed config, and preserves extra sections.
|
||||||
|
def test_save_config_accepts_raw_payload_and_keeps_extras(monkeypatch):
|
||||||
|
manager = ConfigManager.__new__(ConfigManager)
|
||||||
|
manager.raw_payload = {}
|
||||||
|
manager.config = AppConfig(environments=[], settings=GlobalSettings())
|
||||||
|
|
||||||
|
persisted = {}
|
||||||
|
|
||||||
|
def _capture_save(config, session=None):
|
||||||
|
persisted["payload"] = manager.get_payload()
|
||||||
|
|
||||||
|
monkeypatch.setattr(manager, "_save_config_to_db", _capture_save)
|
||||||
|
|
||||||
|
manager.save_config(
|
||||||
|
{
|
||||||
|
"environments": [],
|
||||||
|
"settings": GlobalSettings().model_dump(),
|
||||||
|
"notifications": {"telegram": {"bot_token": "secret"}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert manager.raw_payload["notifications"]["telegram"]["bot_token"] == "secret"
|
||||||
|
assert manager.config.settings.migration_sync_cron == "0 2 * * *"
|
||||||
|
assert persisted["payload"]["notifications"]["telegram"]["bot_token"] == "secret"
|
||||||
|
# [/DEF:test_save_config_accepts_raw_payload_and_keeps_extras:Function]
|
||||||
|
|
||||||
|
# [/DEF:backend.src.core.__tests__.test_config_manager_compat:Module]
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, List
|
from typing import Any, Optional, List
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ class ConfigManager:
|
|||||||
logger.reason(f"Initializing ConfigManager with legacy path: {config_path}")
|
logger.reason(f"Initializing ConfigManager with legacy path: {config_path}")
|
||||||
|
|
||||||
self.config_path = Path(config_path)
|
self.config_path = Path(config_path)
|
||||||
|
self.raw_payload: dict[str, Any] = {}
|
||||||
self.config: AppConfig = self._load_config()
|
self.config: AppConfig = self._load_config()
|
||||||
|
|
||||||
configure_logger(self.config.settings.logging)
|
configure_logger(self.config.settings.logging)
|
||||||
@@ -69,6 +70,20 @@ class ConfigManager:
|
|||||||
)
|
)
|
||||||
# [/DEF:_default_config:Function]
|
# [/DEF:_default_config:Function]
|
||||||
|
|
||||||
|
# [DEF:_sync_raw_payload_from_config:Function]
|
||||||
|
# @PURPOSE: Merge typed AppConfig state into raw payload while preserving unsupported legacy sections.
|
||||||
|
# @PRE: self.config is initialized as AppConfig.
|
||||||
|
# @POST: self.raw_payload contains AppConfig fields refreshed from self.config.
|
||||||
|
# @SIDE_EFFECT: Mutates self.raw_payload in memory.
|
||||||
|
# @DATA_CONTRACT: Input(None) -> Output(dict[str, Any])
|
||||||
|
def _sync_raw_payload_from_config(self) -> dict[str, Any]:
|
||||||
|
with belief_scope("ConfigManager._sync_raw_payload_from_config"):
|
||||||
|
payload = dict(self.raw_payload) if isinstance(self.raw_payload, dict) else {}
|
||||||
|
payload.update(self.config.model_dump())
|
||||||
|
self.raw_payload = payload
|
||||||
|
return payload
|
||||||
|
# [/DEF:_sync_raw_payload_from_config:Function]
|
||||||
|
|
||||||
# [DEF:_load_from_legacy_file:Function]
|
# [DEF:_load_from_legacy_file:Function]
|
||||||
# @PURPOSE: Load legacy JSON configuration for migration fallback path.
|
# @PURPOSE: Load legacy JSON configuration for migration fallback path.
|
||||||
# @PRE: self.config_path is initialized.
|
# @PRE: self.config_path is initialized.
|
||||||
@@ -79,15 +94,18 @@ class ConfigManager:
|
|||||||
with belief_scope("_load_from_legacy_file"):
|
with belief_scope("_load_from_legacy_file"):
|
||||||
if not self.config_path.exists():
|
if not self.config_path.exists():
|
||||||
logger.info("[_load_from_legacy_file][Action] Legacy config file not found, using defaults")
|
logger.info("[_load_from_legacy_file][Action] Legacy config file not found, using defaults")
|
||||||
|
self.raw_payload = self._default_config().model_dump()
|
||||||
return self._default_config()
|
return self._default_config()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(self.config_path, "r", encoding="utf-8") as f:
|
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
|
self.raw_payload = dict(data) if isinstance(data, dict) else {}
|
||||||
logger.info("[_load_from_legacy_file][Coherence:OK] Legacy configuration loaded")
|
logger.info("[_load_from_legacy_file][Coherence:OK] Legacy configuration loaded")
|
||||||
return AppConfig(**data)
|
return AppConfig(**data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[_load_from_legacy_file][Coherence:Failed] Error loading legacy config: {e}")
|
logger.error(f"[_load_from_legacy_file][Coherence:Failed] Error loading legacy config: {e}")
|
||||||
|
self.raw_payload = self._default_config().model_dump()
|
||||||
return self._default_config()
|
return self._default_config()
|
||||||
# [/DEF:_load_from_legacy_file:Function]
|
# [/DEF:_load_from_legacy_file:Function]
|
||||||
|
|
||||||
@@ -115,6 +133,7 @@ class ConfigManager:
|
|||||||
record = self._get_record(session)
|
record = self._get_record(session)
|
||||||
if record and record.payload:
|
if record and record.payload:
|
||||||
logger.reason("Configuration found in database")
|
logger.reason("Configuration found in database")
|
||||||
|
self.raw_payload = dict(record.payload)
|
||||||
config = AppConfig(**record.payload)
|
config = AppConfig(**record.payload)
|
||||||
logger.reflect("Database configuration validated")
|
logger.reflect("Database configuration validated")
|
||||||
return config
|
return config
|
||||||
@@ -126,6 +145,7 @@ class ConfigManager:
|
|||||||
return config
|
return config
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.explore(f"Error loading config from DB: {e}")
|
logger.explore(f"Error loading config from DB: {e}")
|
||||||
|
self.raw_payload = self._default_config().model_dump()
|
||||||
return self._default_config()
|
return self._default_config()
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
@@ -146,7 +166,7 @@ class ConfigManager:
|
|||||||
db = session or SessionLocal()
|
db = session or SessionLocal()
|
||||||
try:
|
try:
|
||||||
record = self._get_record(db)
|
record = self._get_record(db)
|
||||||
payload = config.model_dump()
|
payload = self._sync_raw_payload_from_config()
|
||||||
if record is None:
|
if record is None:
|
||||||
logger.reason("Creating new global configuration record")
|
logger.reason("Creating new global configuration record")
|
||||||
record = AppConfigRecord(id="global", payload=payload)
|
record = AppConfigRecord(id="global", payload=payload)
|
||||||
@@ -187,6 +207,38 @@ class ConfigManager:
|
|||||||
return self.config
|
return self.config
|
||||||
# [/DEF:get_config:Function]
|
# [/DEF:get_config:Function]
|
||||||
|
|
||||||
|
# [DEF:get_payload:Function]
|
||||||
|
# @PURPOSE: Return full persisted payload including sections outside typed AppConfig schema.
|
||||||
|
# @PRE: Manager state is initialized.
|
||||||
|
# @POST: Returns dict payload with current AppConfig fields synchronized.
|
||||||
|
# @SIDE_EFFECT: Refreshes self.raw_payload from self.config before returning.
|
||||||
|
# @DATA_CONTRACT: Input(None) -> Output(dict[str, Any])
|
||||||
|
def get_payload(self) -> dict[str, Any]:
|
||||||
|
with belief_scope("ConfigManager.get_payload"):
|
||||||
|
return dict(self._sync_raw_payload_from_config())
|
||||||
|
# [/DEF:get_payload:Function]
|
||||||
|
|
||||||
|
# [DEF:save_config:Function]
|
||||||
|
# @PURPOSE: Persist configuration provided either as typed AppConfig or raw payload dict.
|
||||||
|
# @PRE: config is AppConfig or dict compatible with AppConfig core schema.
|
||||||
|
# @POST: self.config and self.raw_payload are synchronized and persisted to DB.
|
||||||
|
# @SIDE_EFFECT: Mutates in-memory config state, writes database record, may reconfigure logger.
|
||||||
|
# @DATA_CONTRACT: Input(AppConfig|dict[str, Any]) -> Output(None)
|
||||||
|
def save_config(self, config: AppConfig | dict[str, Any]):
|
||||||
|
with belief_scope("ConfigManager.save_config"):
|
||||||
|
if isinstance(config, AppConfig):
|
||||||
|
self.config = config
|
||||||
|
self.raw_payload = dict(config.model_dump())
|
||||||
|
elif isinstance(config, dict):
|
||||||
|
self.raw_payload = dict(config)
|
||||||
|
self.config = AppConfig(**config)
|
||||||
|
else:
|
||||||
|
raise TypeError("config must be an AppConfig instance or dict payload")
|
||||||
|
|
||||||
|
self._save_config_to_db(self.config)
|
||||||
|
configure_logger(self.config.settings.logging)
|
||||||
|
# [/DEF:save_config:Function]
|
||||||
|
|
||||||
# [DEF:update_global_settings:Function]
|
# [DEF:update_global_settings:Function]
|
||||||
# @PURPOSE: Replace global settings and persist the resulting configuration.
|
# @PURPOSE: Replace global settings and persist the resulting configuration.
|
||||||
# @PRE: settings is GlobalSettings.
|
# @PRE: settings is GlobalSettings.
|
||||||
|
|||||||
61
build.sh
61
build.sh
@@ -5,6 +5,8 @@ set -euo pipefail
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
BACKEND_ENV_FILE="$SCRIPT_DIR/backend/.env"
|
||||||
|
|
||||||
if ! command -v docker >/dev/null 2>&1; then
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
echo "Error: docker is not installed or not in PATH."
|
echo "Error: docker is not installed or not in PATH."
|
||||||
exit 1
|
exit 1
|
||||||
@@ -19,6 +21,65 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
ensure_backend_encryption_key() {
|
||||||
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
|
python3 - "$BACKEND_ENV_FILE" <<'PY'
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_fernet_key(raw_value: str) -> bool:
|
||||||
|
value = raw_value.strip()
|
||||||
|
if not value:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
decoded = base64.urlsafe_b64decode(value.encode())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return len(decoded) == 32
|
||||||
|
|
||||||
|
|
||||||
|
def generate_fernet_key() -> str:
|
||||||
|
return base64.urlsafe_b64encode(os.urandom(32)).decode()
|
||||||
|
|
||||||
|
|
||||||
|
env_path = Path(sys.argv[1])
|
||||||
|
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
existing_lines: list[str] = []
|
||||||
|
existing_key: str | None = None
|
||||||
|
|
||||||
|
if env_path.exists():
|
||||||
|
existing_lines = env_path.read_text(encoding="utf-8").splitlines()
|
||||||
|
for line in existing_lines:
|
||||||
|
if line.startswith("ENCRYPTION_KEY="):
|
||||||
|
candidate = line.partition("=")[2].strip()
|
||||||
|
if is_valid_fernet_key(candidate):
|
||||||
|
existing_key = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
if existing_key is None:
|
||||||
|
generated_key = generate_fernet_key()
|
||||||
|
filtered_lines = [line for line in existing_lines if not line.startswith("ENCRYPTION_KEY=")]
|
||||||
|
filtered_lines.append(f"ENCRYPTION_KEY={generated_key}")
|
||||||
|
env_path.write_text("\n".join(filtered_lines) + "\n", encoding="utf-8")
|
||||||
|
print(f"[build] ENCRYPTION_KEY ensured in {env_path}")
|
||||||
|
else:
|
||||||
|
print(f"[build] Existing ENCRYPTION_KEY reused from {env_path}")
|
||||||
|
PY
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Error: python3 is required to generate backend/.env with ENCRYPTION_KEY."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_backend_encryption_key
|
||||||
|
|
||||||
echo "[1/2] Building project images..."
|
echo "[1/2] Building project images..."
|
||||||
"${COMPOSE_CMD[@]}" build
|
"${COMPOSE_CMD[@]}" build
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ services:
|
|||||||
dockerfile: docker/backend.Dockerfile
|
dockerfile: docker/backend.Dockerfile
|
||||||
container_name: ss_tools_backend
|
container_name: ss_tools_backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- ./backend/.env
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
let testStatus = $state({ type: "", message: "" });
|
let testStatus = $state({ type: "", message: "" });
|
||||||
let isTesting = $state(false);
|
let isTesting = $state(false);
|
||||||
let togglingProviderIds = $state(new Set());
|
let togglingProviderIds = $state(new Set());
|
||||||
|
let deletingProviderIds = $state(new Set());
|
||||||
|
|
||||||
function isMultimodalModel(modelName) {
|
function isMultimodalModel(modelName) {
|
||||||
const token = (modelName || "").toLowerCase();
|
const token = (modelName || "").toLowerCase();
|
||||||
@@ -155,6 +156,38 @@
|
|||||||
onSave();
|
onSave();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDelete(provider) {
|
||||||
|
if (deletingProviderIds.has(provider.id)) return;
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
$t.llm.delete_confirm.replace("{name}", provider.name || provider.id),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deletingProviderIds.add(provider.id);
|
||||||
|
deletingProviderIds = new Set(deletingProviderIds);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requestApi(`/llm/providers/${provider.id}`, "DELETE");
|
||||||
|
providers = providers.filter(({ id }) => id !== provider.id);
|
||||||
|
addToast($t.llm.delete_success.replace("{name}", provider.name), "success");
|
||||||
|
onSave();
|
||||||
|
} catch (err) {
|
||||||
|
addToast(
|
||||||
|
$t.llm.delete_failed.replace(
|
||||||
|
"{error}",
|
||||||
|
err?.message || $t.common.error,
|
||||||
|
),
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
deletingProviderIds.delete(provider.id);
|
||||||
|
deletingProviderIds = new Set(deletingProviderIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
@@ -345,11 +378,23 @@
|
|||||||
>
|
>
|
||||||
{$t.common.edit}
|
{$t.common.edit}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-sm text-red-600 hover:underline"
|
||||||
|
onclick={() => handleDelete(provider)}
|
||||||
|
disabled={deletingProviderIds.has(provider.id)}
|
||||||
|
>
|
||||||
|
{#if deletingProviderIds.has(provider.id)}
|
||||||
|
...
|
||||||
|
{:else}
|
||||||
|
{$t.common.delete}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`text-sm ${provider.is_active ? "text-orange-600" : "text-green-600"} hover:underline`}
|
class={`text-sm ${provider.is_active ? "text-orange-600" : "text-green-600"} hover:underline`}
|
||||||
onclick={() => toggleActive(provider)}
|
onclick={() => toggleActive(provider)}
|
||||||
disabled={togglingProviderIds.has(provider.id)}
|
disabled={togglingProviderIds.has(provider.id) || deletingProviderIds.has(provider.id)}
|
||||||
>
|
>
|
||||||
{#if togglingProviderIds.has(provider.id)}
|
{#if togglingProviderIds.has(provider.id)}
|
||||||
...
|
...
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// [DEF:frontend.src.components.llm.__tests__.provider_config_integration:Module]
|
// [DEF:frontend.src.components.llm.__tests__.provider_config_integration:Module]
|
||||||
// @TIER: STANDARD
|
// @TIER: STANDARD
|
||||||
// @SEMANTICS: llm, provider-config, integration-test, edit-flow
|
// @SEMANTICS: llm, provider-config, integration-test, edit-flow, delete-flow
|
||||||
// @PURPOSE: Protect edit-button interaction contract in LLM provider settings UI.
|
// @PURPOSE: Protect edit and delete interaction contracts in LLM provider settings UI.
|
||||||
// @LAYER: UI Tests
|
// @LAYER: UI Tests
|
||||||
// @RELATION: VERIFIES -> frontend/src/components/llm/ProviderConfig.svelte
|
// @RELATION: VERIFIES -> frontend/src/components/llm/ProviderConfig.svelte
|
||||||
// @INVARIANT: Edit action keeps explicit click handler and opens normalized edit form.
|
// @INVARIANT: Edit action keeps explicit click handler and opens normalized edit form.
|
||||||
@@ -17,7 +17,7 @@ const COMPONENT_PATH = path.resolve(
|
|||||||
|
|
||||||
// [DEF:provider_config_edit_contract_tests:Function]
|
// [DEF:provider_config_edit_contract_tests:Function]
|
||||||
// @TIER: STANDARD
|
// @TIER: STANDARD
|
||||||
// @PURPOSE: Validate edit button handler wiring and normalized edit form state mapping.
|
// @PURPOSE: Validate edit and delete handler wiring plus normalized edit form state mapping.
|
||||||
// @PRE: ProviderConfig component source exists in expected path.
|
// @PRE: ProviderConfig component source exists in expected path.
|
||||||
// @POST: Contract checks ensure edit click cannot degrade into no-op flow.
|
// @POST: Contract checks ensure edit click cannot degrade into no-op flow.
|
||||||
describe('ProviderConfig edit interaction contract', () => {
|
describe('ProviderConfig edit interaction contract', () => {
|
||||||
@@ -25,9 +25,7 @@ describe('ProviderConfig edit interaction contract', () => {
|
|||||||
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
|
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
|
||||||
|
|
||||||
expect(source).toContain('type="button"');
|
expect(source).toContain('type="button"');
|
||||||
expect(source).toContain(
|
expect(source).toContain('onclick={() => handleEdit(provider)}');
|
||||||
"on:click|preventDefault|stopPropagation={() => handleEdit(provider)}",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes provider payload into editable form shape', () => {
|
it('normalizes provider payload into editable form shape', () => {
|
||||||
@@ -39,6 +37,15 @@ describe('ProviderConfig edit interaction contract', () => {
|
|||||||
expect(source).toContain('default_model: provider?.default_model ?? "gpt-4o"');
|
expect(source).toContain('default_model: provider?.default_model ?? "gpt-4o"');
|
||||||
expect(source).toContain('showForm = true;');
|
expect(source).toContain('showForm = true;');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps explicit delete flow with confirmation and delete request', () => {
|
||||||
|
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
|
||||||
|
|
||||||
|
expect(source).toContain('async function handleDelete(provider)');
|
||||||
|
expect(source).toContain('$t.llm.delete_confirm.replace("{name}", provider.name || provider.id)');
|
||||||
|
expect(source).toContain('await requestApi(`/llm/providers/${provider.id}`, "DELETE")');
|
||||||
|
expect(source).toContain('onclick={() => handleDelete(provider)}');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
// [/DEF:provider_config_edit_contract_tests:Function]
|
// [/DEF:provider_config_edit_contract_tests:Function]
|
||||||
// [/DEF:frontend.src.components.llm.__tests__.provider_config_integration:Module]
|
// [/DEF:frontend.src.components.llm.__tests__.provider_config_integration:Module]
|
||||||
|
|||||||
@@ -111,6 +111,9 @@
|
|||||||
"connection_success": "Connection successful!",
|
"connection_success": "Connection successful!",
|
||||||
"connection_failed": "Connection failed: {error}",
|
"connection_failed": "Connection failed: {error}",
|
||||||
"no_providers": "No providers configured.",
|
"no_providers": "No providers configured.",
|
||||||
|
"delete_confirm": "Delete LLM provider \"{name}\"?",
|
||||||
|
"delete_success": "Provider \"{name}\" deleted.",
|
||||||
|
"delete_failed": "Failed to delete provider: {error}",
|
||||||
"multimodal": "Multimodal",
|
"multimodal": "Multimodal",
|
||||||
"text_only": "Text only",
|
"text_only": "Text only",
|
||||||
"doc_preview_title": "Documentation Preview",
|
"doc_preview_title": "Documentation Preview",
|
||||||
|
|||||||
@@ -111,6 +111,9 @@
|
|||||||
"connection_success": "Подключение успешно!",
|
"connection_success": "Подключение успешно!",
|
||||||
"connection_failed": "Ошибка подключения: {error}",
|
"connection_failed": "Ошибка подключения: {error}",
|
||||||
"no_providers": "Провайдеры не настроены.",
|
"no_providers": "Провайдеры не настроены.",
|
||||||
|
"delete_confirm": "Удалить LLM-провайдера \"{name}\"?",
|
||||||
|
"delete_success": "Провайдер \"{name}\" удалён.",
|
||||||
|
"delete_failed": "Не удалось удалить провайдера: {error}",
|
||||||
"multimodal": "Мультимодальная",
|
"multimodal": "Мультимодальная",
|
||||||
"text_only": "Только текст",
|
"text_only": "Только текст",
|
||||||
"doc_preview_title": "Предпросмотр документации",
|
"doc_preview_title": "Предпросмотр документации",
|
||||||
|
|||||||
Reference in New Issue
Block a user