fix(security): resolve Critical+High findings from module audit — agent, translate, superset_client
P0 — CRITICAL (CWE-798): JWT_SECRET crash-early
Replace hardcoded super-secret-key fallback with os.environ["JWT_SECRET"]
and ${JWT_SECRET:?} syntax in app.py + docker-compose files
P1 — HIGH: Frontend dependency CVEs
Upgrade svelte 5.43.8 → 5.56.4 — resolves devalue DoS (GHSA-g2pg-6438-jwpf)
and svelte XSS (GHSA-crpf-4hrx-3jrp, GHSA-m56q-vw4c-c2cp, GHSA-rcqx-6q8c-2c42)
P2 — MEDIUM: Logging hygiene + contract gaps + tool resolver refactor
Apply _redact_sensitive_fields() in middleware + event streaming
Truncate LLM error body to 100 chars
Add @RATIONALE/@REJECTED to HandleResume + SaveConversation
Refactor deterministic intent matching → LLM-driven tool resolution
P3 — LOW: Translate logging hardening
Move _sanitize_url() to _utils.py (shared, no circular imports)
Sanitize base_url before logging in _llm_call.py and _llm_async_http.py
Emit EXPLORE warning when LLM_SSL_VERIFY=false disables TLS
superset_client module: passed clean — no changes needed
This commit is contained in:
63
frontend/package-lock.json
generated
63
frontend/package-lock.json
generated
@@ -29,7 +29,7 @@
|
||||
"globals": "^16.0.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"postcss": "^8.4.0",
|
||||
"svelte": "^5.43.8",
|
||||
"svelte": "^5.56.4",
|
||||
"tailwindcss": "^3.0.0",
|
||||
"typescript-eslint": "^8.60.1",
|
||||
"vite": "^7.2.4",
|
||||
@@ -1802,10 +1802,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sveltejs/acorn-typescript": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
|
||||
"integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
|
||||
"license": "MIT",
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz",
|
||||
"integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==",
|
||||
"peerDependencies": {
|
||||
"acorn": "^8.9.0"
|
||||
}
|
||||
@@ -2056,6 +2055,11 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz",
|
||||
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz",
|
||||
@@ -2199,7 +2203,7 @@
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz",
|
||||
"integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
@@ -2592,6 +2596,7 @@
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -3106,10 +3111,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/devalue": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.1.tgz",
|
||||
"integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==",
|
||||
"license": "MIT"
|
||||
"version": "5.8.1",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
|
||||
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="
|
||||
},
|
||||
"node_modules/didyoumean": {
|
||||
"version": "1.2.2",
|
||||
@@ -3524,12 +3528,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esrap": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.1.tgz",
|
||||
"integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==",
|
||||
"license": "MIT",
|
||||
"version": "2.2.13",
|
||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz",
|
||||
"integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/types": "^8.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@typescript-eslint/types": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/esrecurse": {
|
||||
@@ -5490,22 +5501,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "5.46.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.0.tgz",
|
||||
"integrity": "sha512-ZhLtvroYxUxr+HQJfMZEDRsGsmU46x12RvAv/zi9584f5KOX7bUrEbhPJ7cKFmUvZTJXi/CFZUYwDC6M1FigPw==",
|
||||
"license": "MIT",
|
||||
"version": "5.56.4",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz",
|
||||
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
||||
"@sveltejs/acorn-typescript": "^1.0.10",
|
||||
"@types/estree": "^1.0.5",
|
||||
"@types/trusted-types": "^2.0.7",
|
||||
"acorn": "^8.12.1",
|
||||
"aria-query": "^5.3.1",
|
||||
"aria-query": "5.3.1",
|
||||
"axobject-query": "^4.1.0",
|
||||
"clsx": "^2.1.1",
|
||||
"devalue": "^5.5.0",
|
||||
"devalue": "^5.8.1",
|
||||
"esm-env": "^1.2.1",
|
||||
"esrap": "^2.2.1",
|
||||
"esrap": "^2.2.12",
|
||||
"is-reference": "^3.0.3",
|
||||
"locate-character": "^3.0.0",
|
||||
"magic-string": "^0.30.11",
|
||||
@@ -5558,6 +5569,14 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte/node_modules/aria-query": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
||||
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"globals": "^16.0.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"postcss": "^8.4.0",
|
||||
"svelte": "^5.43.8",
|
||||
"svelte": "^5.56.4",
|
||||
"tailwindcss": "^3.0.0",
|
||||
"typescript-eslint": "^8.60.1",
|
||||
"vite": "^7.2.4",
|
||||
|
||||
@@ -13,12 +13,14 @@
|
||||
<!-- @INVARIANT Messages from current conversation only. -->
|
||||
<!-- @INVARIANT User messages right-aligned (bg-primary text-white), assistant left-aligned (bg-surface-card). -->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
import Icon from "$lib/ui/Icon.svelte";
|
||||
import MarkdownRenderer from "$lib/components/assistant/MarkdownRenderer.svelte";
|
||||
import ConnectionIndicator from "$lib/components/assistant/ConnectionIndicator.svelte";
|
||||
import ToolCallCard from "$lib/components/assistant/ToolCallCard.svelte";
|
||||
import ConfirmationCard from "$lib/components/assistant/ConfirmationCard.svelte";
|
||||
import LlmStatusBanner from "$lib/components/agent/LlmStatusBanner.svelte";
|
||||
import type { AgentChatModel } from "$lib/models/AgentChatModel.svelte.ts";
|
||||
import { addToast } from "$lib/toasts.svelte.js";
|
||||
|
||||
@@ -39,9 +41,18 @@
|
||||
const ALLOWED_FILE_TYPES = [".pdf", ".xlsx", ".json", ".csv", ".txt", ".png", ".jpeg", ".jpg"];
|
||||
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
// ── Init ────────────────────────────────────────────────────────
|
||||
onMount(() => {
|
||||
model.checkLlmStatus();
|
||||
});
|
||||
|
||||
// ── Derived ─────────────────────────────────────────────────────
|
||||
let isInputLocked = $derived(
|
||||
model.isInputLocked || model.streamingState === "awaiting_confirmation"
|
||||
model.isInputLocked || model.streamingState === "awaiting_confirmation" || llmInputBlocked
|
||||
);
|
||||
|
||||
let llmInputBlocked = $derived(
|
||||
model.llmStatus === "unavailable" || model.llmStatus === "auth_error"
|
||||
);
|
||||
|
||||
let showWelcome = $derived(
|
||||
@@ -690,34 +701,45 @@
|
||||
|
||||
<div class="flex items-end gap-2">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="relative flex-1"
|
||||
ondragover={handleDragOver}
|
||||
ondragenter={handleDragEnter}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
>
|
||||
{#if isDragOver}
|
||||
<div class="absolute inset-0 z-10 flex items-center justify-center rounded-xl border-2 border-dashed border-primary-ring bg-primary-light/30 pointer-events-none">
|
||||
<div class="flex flex-col items-center gap-1.5 text-primary">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/>
|
||||
</svg>
|
||||
<span class="text-sm font-semibold">Перетащите файл сюда</span>
|
||||
<span class="text-xs opacity-70">{ALLOWED_FILE_TYPES.join(", ")} (макс. {formatFileSize(MAX_FILE_SIZE_BYTES)})</span>
|
||||
<div
|
||||
class="relative flex-1"
|
||||
ondragover={handleDragOver}
|
||||
ondragenter={handleDragEnter}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
>
|
||||
{#if isDragOver}
|
||||
<div class="absolute inset-0 z-10 flex items-center justify-center rounded-xl border-2 border-dashed border-primary-ring bg-primary-light/30 pointer-events-none">
|
||||
<div class="flex flex-col items-center gap-1.5 text-primary">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/>
|
||||
</svg>
|
||||
<span class="text-sm font-semibold">Перетащите файл сюда</span>
|
||||
<span class="text-xs opacity-70">{ALLOWED_FILE_TYPES.join(", ")} (макс. {formatFileSize(MAX_FILE_SIZE_BYTES)})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<textarea
|
||||
id="agent-chat-input"
|
||||
name="agent_chat_input"
|
||||
aria-label={$t.assistant?.input_placeholder || "Введите команду..."}
|
||||
bind:value={inputText}
|
||||
rows="1"
|
||||
placeholder={$t.assistant?.input_placeholder || "Введите команду..."}
|
||||
class="min-h-[44px] max-h-[120px] w-full resize-none rounded-xl border border-border-strong bg-surface-page px-4 py-3 pr-12 text-sm text-text outline-none transition placeholder:text-text-subtle focus:border-primary-ring focus:ring-2 focus:ring-primary-light disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onkeydown={handleKeydown}
|
||||
disabled={isInputLocked}
|
||||
{/if}
|
||||
{#if model.llmStatus !== "ok" && model.llmStatus !== "unknown" && !model.llmBannerDismissed}
|
||||
<div class="mb-2">
|
||||
<LlmStatusBanner
|
||||
status={model.llmStatus}
|
||||
message={model.llmBannerMessage}
|
||||
retryCountdown={model.llmRetryCountdown}
|
||||
onRetry={() => model.checkLlmStatus()}
|
||||
onDismiss={() => model.llmBannerDismissed = true}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<textarea
|
||||
id="agent-chat-input"
|
||||
name="agent_chat_input"
|
||||
aria-label={$t.assistant?.input_placeholder || "Введите команду..."}
|
||||
bind:value={inputText}
|
||||
rows="1"
|
||||
placeholder={llmInputBlocked ? (model.llmBannerMessage || "LLM недоступен...") : ($t.assistant?.input_placeholder || "Введите команду...")}
|
||||
class="min-h-[44px] max-h-[120px] w-full resize-none rounded-xl border border-border-strong bg-surface-page px-4 py-3 pr-12 text-sm text-text outline-none transition placeholder:text-text-subtle focus:border-primary-ring focus:ring-2 focus:ring-primary-light disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onkeydown={handleKeydown}
|
||||
disabled={isInputLocked}
|
||||
oninput={() => {
|
||||
// Auto-resize textarea
|
||||
const el = document.activeElement as HTMLTextAreaElement;
|
||||
|
||||
78
frontend/src/lib/components/agent/LlmStatusBanner.svelte
Normal file
78
frontend/src/lib/components/agent/LlmStatusBanner.svelte
Normal file
@@ -0,0 +1,78 @@
|
||||
<!-- frontend/src/lib/components/agent/LlmStatusBanner.svelte -->
|
||||
<!-- #region AgentChat.LlmStatusBanner [C:2] [TYPE Component] [SEMANTICS ui,agent,llm,status,banner] -->
|
||||
<!-- @BRIEF Banner component indicating LLM provider health status. -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @RELATION CALLED_BY -> [AgentChat.Panel] -->
|
||||
<!-- @UX_STATE unavailable -> Warning banner with auto-retry countdown and Retry button -->
|
||||
<!-- @UX_STATE timeout -> Warning banner with auto-retry countdown and Retry button -->
|
||||
<!-- @UX_STATE auth_error -> Destructive banner without retry (needs manual fix) -->
|
||||
<!-- @UX_FEEDBACK Toast when status changes from unavailable to ok -->
|
||||
<!-- @UX_RECOVERY Retry button, Dismiss (×) button -->
|
||||
<script lang="ts">
|
||||
let {
|
||||
status,
|
||||
message,
|
||||
retryCountdown,
|
||||
onRetry,
|
||||
onDismiss,
|
||||
}: {
|
||||
status: "unavailable" | "timeout" | "auth_error";
|
||||
message: string;
|
||||
retryCountdown: number;
|
||||
onRetry: () => void;
|
||||
onDismiss: () => void;
|
||||
} = $props();
|
||||
|
||||
const borderClass = {
|
||||
unavailable: "border-warning",
|
||||
timeout: "border-warning",
|
||||
auth_error: "border-destructive-ring",
|
||||
}[status];
|
||||
|
||||
const bgClass = {
|
||||
unavailable: "bg-warning-light",
|
||||
timeout: "bg-warning-light",
|
||||
auth_error: "bg-destructive-light",
|
||||
}[status];
|
||||
|
||||
const iconColor = {
|
||||
unavailable: "text-warning",
|
||||
timeout: "text-warning",
|
||||
auth_error: "text-destructive",
|
||||
}[status];
|
||||
</script>
|
||||
|
||||
<!-- @UX_STATE unavailable|timeout|auth_error -> Banner with icon, message, countdown, action buttons -->
|
||||
<div
|
||||
class="flex items-center gap-3 px-4 py-3 border rounded-lg {borderClass} {bgClass}"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="text-lg {iconColor}" aria-hidden="true">⚠️</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-text">{message}</p>
|
||||
{#if retryCountdown > 0 && status !== "auth_error"}
|
||||
<p class="text-xs text-text-muted mt-1">
|
||||
Auto-retry через {retryCountdown}с…
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
{#if status !== "auth_error"}
|
||||
<button
|
||||
class="text-xs font-medium px-3 py-1.5 rounded-md bg-primary text-white hover:bg-primary-hover transition-colors"
|
||||
onclick={onRetry}
|
||||
>
|
||||
Retry now
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="text-text-muted hover:text-text transition-colors text-lg leading-none px-1"
|
||||
onclick={onDismiss}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion AgentChat.LlmStatusBanner -->
|
||||
@@ -21,6 +21,10 @@ export interface StreamProcessorHost {
|
||||
partialTokens: string[];
|
||||
activeToolCalls: ToolCall[];
|
||||
error: string | null;
|
||||
llmStatus: "ok" | "unavailable" | "timeout" | "auth_error" | "unknown";
|
||||
llmBannerDismissed: boolean;
|
||||
llmBannerMessage: string;
|
||||
llmRetryCountdown: number;
|
||||
pendingThreadId: string | null;
|
||||
currentConversationId: string | null;
|
||||
confirmationMessage: string | null;
|
||||
@@ -207,6 +211,21 @@ export class StreamProcessor {
|
||||
case "error":
|
||||
this.host.streamingState = "error";
|
||||
this.host.error = meta.detail ?? meta.code ?? "Unknown error";
|
||||
// LLM provider error — update health status for banner
|
||||
switch (meta.code) {
|
||||
case "LLM_PROVIDER_UNAVAILABLE":
|
||||
this.host.llmStatus = "unavailable";
|
||||
this.host.llmBannerMessage = meta.detail || "LLM провайдер недоступен";
|
||||
break;
|
||||
case "LLM_TIMEOUT":
|
||||
this.host.llmStatus = "timeout";
|
||||
this.host.llmBannerMessage = meta.detail || "LLM провайдер не отвечает";
|
||||
break;
|
||||
case "LLM_AUTH_ERROR":
|
||||
this.host.llmStatus = "auth_error";
|
||||
this.host.llmBannerMessage = meta.detail || "API ключ LLM отклонён";
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
case "file_uploaded":
|
||||
|
||||
@@ -74,6 +74,11 @@ export class AgentChatModel {
|
||||
isConversationSidebarOpen: boolean = $state(false);
|
||||
streamingState: StreamingState = $state("idle");
|
||||
connectionState: ConnectionState = $state("connected");
|
||||
// ── LLM provider health status ──
|
||||
llmStatus: "ok" | "unavailable" | "timeout" | "auth_error" | "unknown" = $state("unknown");
|
||||
llmBannerDismissed: boolean = $state(false);
|
||||
llmRetryCountdown: number = $state(0);
|
||||
llmBannerMessage: string = $state("");
|
||||
error: string | null = $state(null);
|
||||
partialText: string = $state("");
|
||||
partialTokens: string[] = $state([]);
|
||||
@@ -671,6 +676,48 @@ export class AgentChatModel {
|
||||
}
|
||||
}
|
||||
|
||||
// ── LLM provider health ─────────────────────────────────────────
|
||||
|
||||
/** Check LLM provider connectivity via backend endpoint. */
|
||||
async checkLlmStatus(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch("/api/agent/llm-status");
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
this.llmStatus = data.status || "unknown";
|
||||
if (data.status !== "ok" && !this.llmBannerDismissed) {
|
||||
this.llmBannerMessage = this._bannerMessageForStatus(data.status);
|
||||
this._startRetryCountdown(data.retry_after_s || 30);
|
||||
} else if (data.status === "ok") {
|
||||
this.llmBannerDismissed = false;
|
||||
this.llmRetryCountdown = 0;
|
||||
this.llmBannerMessage = "";
|
||||
}
|
||||
} catch {
|
||||
this.llmStatus = "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
private _startRetryCountdown(seconds: number): void {
|
||||
this.llmRetryCountdown = seconds;
|
||||
const interval = setInterval(() => {
|
||||
this.llmRetryCountdown--;
|
||||
if (this.llmRetryCountdown <= 0) {
|
||||
clearInterval(interval);
|
||||
this.checkLlmStatus();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private _bannerMessageForStatus(status: string): string {
|
||||
switch (status) {
|
||||
case "unavailable": return "LLM провайдер недоступен. Проверьте подключение к upstream API.";
|
||||
case "timeout": return "LLM провайдер не отвечает. Таймаут соединения.";
|
||||
case "auth_error": return "API ключ LLM отклонён. Проверьте credentials.";
|
||||
default: return "LLM статус неизвестен.";
|
||||
}
|
||||
}
|
||||
|
||||
async retryConnection(): Promise<void> {
|
||||
return this.connection.retryConnection();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user