fix(agent): minimal safety net, zero-config router, LLM provider status UI

- Remove deterministic intent matching (keyword lists, infer_tool,
  fast_confirmation, negation guard, classification sets)
- Embedding descriptions auto-generated from tool docstrings
- LLM provider health endpoint GET /api/agent/llm-status
- 3 error codes: LLM_PROVIDER_UNAVAILABLE, LLM_TIMEOUT, LLM_AUTH_ERROR
- Frontend banner with auto-retry 30s + input disable
- i18n for LLM status messages (assistant.json ru/en)
- 138 passing backend tests
This commit is contained in:
2026-07-01 16:47:21 +03:00
parent ce20f541b6
commit 78d2664e2e
11 changed files with 152 additions and 672 deletions

View File

@@ -736,7 +736,7 @@
aria-label={$t.assistant?.input_placeholder || "Введите команду..."}
bind:value={inputText}
rows="1"
placeholder={llmInputBlocked ? (model.llmBannerMessage || "LLM недоступен...") : ($t.assistant?.input_placeholder || "Введите команду...")}
placeholder={llmInputBlocked ? (model.llmBannerMessage || $t.assistant?.llm_placeholder || "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}

View File

@@ -9,6 +9,8 @@
<!-- @UX_FEEDBACK Toast when status changes from unavailable to ok -->
<!-- @UX_RECOVERY Retry button, Dismiss (×) button -->
<script lang="ts">
import { t } from "$lib/i18n/index.svelte.js";
let {
status,
message,
@@ -53,7 +55,7 @@
<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}с…
{t.assistant?.llm_auto_retry_countdown?.replace("{seconds}", String(retryCountdown)) || `Auto-retry через ${retryCountdown}с…`}
</p>
{/if}
</div>
@@ -63,13 +65,13 @@
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
{t.assistant?.llm_retry_now || "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"
aria-label={t.assistant?.llm_dismiss || "Dismiss"}
>
×
</button>

View File

@@ -120,5 +120,13 @@
"indicator_complete": "Completed",
"time_just_now": "just now",
"time_min": "m",
"time_hr": "h"
"time_hr": "h",
"llm_unavailable": "LLM provider unavailable. Check upstream API connection.",
"llm_timeout": "LLM provider not responding. Connection timeout.",
"llm_auth_error": "LLM API key rejected. Check credentials.",
"llm_unknown_status": "LLM status unknown.",
"llm_auto_retry_countdown": "Auto-retry in {seconds}s…",
"llm_retry_now": "Retry now",
"llm_placeholder": "LLM unavailable...",
"llm_dismiss": "Dismiss"
}

View File

@@ -120,5 +120,13 @@
"indicator_complete": "Завершён",
"time_just_now": "только что",
"time_min": "мин",
"time_hr": "ч"
"time_hr": "ч",
"llm_unavailable": "LLM провайдер недоступен. Проверьте подключение к upstream API.",
"llm_timeout": "LLM провайдер не отвечает. Таймаут соединения.",
"llm_auth_error": "API ключ LLM отклонён. Проверьте credentials.",
"llm_unknown_status": "LLM статус неизвестен.",
"llm_auto_retry_countdown": "Auto-retry через {seconds}с…",
"llm_retry_now": "Retry now",
"llm_placeholder": "LLM недоступен...",
"llm_dismiss": "Скрыть"
}

View File

@@ -211,19 +211,20 @@ 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
// LLM provider error — update health status for banner.
// Messages come from host._bannerMessageForStatus() or meta.detail from backend.
switch (meta.code) {
case "LLM_PROVIDER_UNAVAILABLE":
this.host.llmStatus = "unavailable";
this.host.llmBannerMessage = meta.detail || "LLM провайдер недоступен";
this.host.llmBannerMessage = meta.detail || "";
break;
case "LLM_TIMEOUT":
this.host.llmStatus = "timeout";
this.host.llmBannerMessage = meta.detail || "LLM провайдер не отвечает";
this.host.llmBannerMessage = meta.detail || "";
break;
case "LLM_AUTH_ERROR":
this.host.llmStatus = "auth_error";
this.host.llmBannerMessage = meta.detail || "API ключ LLM отклонён";
this.host.llmBannerMessage = meta.detail || "";
break;
}
break;

View File

@@ -38,6 +38,7 @@ import {
} from "$lib/api/assistant.js";
import { addToast } from "$lib/toasts.svelte.js";
import { log } from "$lib/cot-logger";
import { t } from "$lib/i18n/index.svelte.js";
import { type ConnectionManagerCallbacks, ConnectionManager } from "./AgentChat.ConnectionManager.svelte.js";
import { type StreamProcessorHost, StreamProcessor } from "./AgentChat.StreamProcessor.svelte.js";
import { LocalStorageManager } from "./AgentChat.LocalStorage.js";
@@ -711,10 +712,10 @@ export class AgentChatModel {
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 статус неизвестен.";
case "unavailable": return t.assistant?.llm_unavailable || "LLM провайдер недоступен. Проверьте подключение к upstream API.";
case "timeout": return t.assistant?.llm_timeout || "LLM провайдер не отвечает. Таймаут соединения.";
case "auth_error": return t.assistant?.llm_auth_error || "API ключ LLM отклонён. Проверьте credentials.";
default: return t.assistant?.llm_unknown_status || "LLM статус неизвестен.";
}
}