Improve agent UX and spec sync
This commit is contained in:
@@ -36,6 +36,7 @@
|
||||
let messagesContainer: HTMLDivElement | undefined = $state();
|
||||
let pendingFile: File | null = $state(null);
|
||||
let isDragOver = $state(false);
|
||||
let isDiagnosticsMenuOpen = $state(false);
|
||||
let dragCounter = 0;
|
||||
|
||||
const ALLOWED_FILE_TYPES = [".pdf", ".xlsx", ".json", ".csv", ".txt", ".png", ".jpeg", ".jpg"];
|
||||
@@ -268,6 +269,17 @@
|
||||
pendingFile = file;
|
||||
}
|
||||
|
||||
function closeDiagnosticsMenu() {
|
||||
isDiagnosticsMenuOpen = false;
|
||||
}
|
||||
|
||||
function handleDiagnosticsKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
closeDiagnosticsMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||
@@ -286,6 +298,13 @@
|
||||
return "border-border bg-surface-muted text-text-muted";
|
||||
}
|
||||
|
||||
function processStepClasses(state: string): string {
|
||||
if (state === "done") return "border-success bg-success-light text-success";
|
||||
if (state === "active") return "border-warning bg-warning-light text-warning";
|
||||
if (state === "blocked") return "border-destructive-ring bg-destructive-light text-destructive";
|
||||
return "border-border bg-surface-page text-text-subtle";
|
||||
}
|
||||
|
||||
async function copyDebugInfo() {
|
||||
const lastMsg = model.messages.at(-1);
|
||||
const payload = {
|
||||
@@ -380,29 +399,57 @@
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
class={`rounded-lg border px-2.5 py-1.5 text-xs font-medium transition ${model.isDebugPanelOpen
|
||||
? "border-primary-ring bg-primary-light text-primary"
|
||||
: "border-border-strong bg-surface-card text-text hover:bg-surface-muted"}`}
|
||||
onclick={() => model.toggleDebugPanel()}
|
||||
title="Agent debug information"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon name="info" size={14} />
|
||||
<span class="hidden sm:inline">Info</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="hidden rounded-lg border border-border-strong bg-surface-card px-2.5 py-1.5 text-xs font-medium text-text transition hover:bg-surface-muted sm:inline-flex"
|
||||
onclick={copyDebugInfo}
|
||||
title="Copy agent debug information"
|
||||
aria-label="Copy agent debug information"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon name="copy" size={14} />
|
||||
<span class="hidden sm:inline">Copy</span>
|
||||
</div>
|
||||
</button>
|
||||
<div class="relative" onkeydown={handleDiagnosticsKeydown}>
|
||||
<button
|
||||
class={`rounded-lg border px-2.5 py-1.5 text-xs font-medium transition ${model.isDebugPanelOpen || isDiagnosticsMenuOpen
|
||||
? "border-primary-ring bg-primary-light text-primary"
|
||||
: "border-border-strong bg-surface-card text-text hover:bg-surface-muted"}`}
|
||||
onclick={() => isDiagnosticsMenuOpen = !isDiagnosticsMenuOpen}
|
||||
title="Диагностика агента"
|
||||
aria-label="Диагностика агента"
|
||||
aria-expanded={isDiagnosticsMenuOpen}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon name="info" size={14} />
|
||||
<span class="hidden sm:inline">Диагностика</span>
|
||||
</div>
|
||||
</button>
|
||||
{#if isDiagnosticsMenuOpen}
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-10 cursor-default"
|
||||
aria-label="Закрыть меню диагностики"
|
||||
onclick={closeDiagnosticsMenu}
|
||||
tabindex="-1"
|
||||
></button>
|
||||
<div class="absolute right-0 top-9 z-20 w-56 rounded-lg border border-border bg-surface-card p-1.5 text-xs shadow-lg" role="menu" aria-label="Диагностика агента">
|
||||
<button
|
||||
class="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-text hover:bg-surface-muted"
|
||||
onclick={() => { model.toggleDebugPanel(); isDiagnosticsMenuOpen = false; }}
|
||||
role="menuitem"
|
||||
>
|
||||
<Icon name="info" size={14} />
|
||||
{model.isDebugPanelOpen ? "Скрыть debug-панель" : "Показать debug-панель"}
|
||||
</button>
|
||||
<button
|
||||
class="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-text hover:bg-surface-muted"
|
||||
onclick={() => { copyDebugInfo(); isDiagnosticsMenuOpen = false; }}
|
||||
role="menuitem"
|
||||
>
|
||||
<Icon name="copy" size={14} />
|
||||
Скопировать debug info
|
||||
</button>
|
||||
<button
|
||||
class="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-text hover:bg-surface-muted"
|
||||
onclick={() => { model.checkLlmStatus(); isDiagnosticsMenuOpen = false; }}
|
||||
role="menuitem"
|
||||
>
|
||||
<Icon name="refresh" size={14} />
|
||||
Проверить LLM
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if model.streamingState === "streaming"}
|
||||
<button
|
||||
class="rounded-lg border border-destructive-ring bg-destructive-light px-3 py-1.5 text-xs font-semibold text-destructive transition hover:bg-destructive hover:text-white"
|
||||
@@ -445,6 +492,32 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-3 rounded-lg border border-border bg-surface-page px-3 py-2">
|
||||
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={`h-2 w-2 shrink-0 rounded-full ${model.agentPhaseTone === "destructive" ? "bg-destructive" : model.agentPhaseTone === "warning" ? "bg-warning" : model.agentPhaseTone === "success" ? "bg-success" : "bg-border-strong"}`}></span>
|
||||
<span class="truncate text-xs font-semibold text-text">{model.userFacingStatusTitle}</span>
|
||||
</div>
|
||||
<p class="mt-0.5 truncate text-xs text-text-muted">{model.userFacingStatusDetail}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap gap-1.5">
|
||||
{#each model.processSteps as step}
|
||||
<span class={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-medium ${processStepClasses(step.state)}`}>
|
||||
{#if step.state === "done"}
|
||||
<Icon name="check" size={12} />
|
||||
{:else if step.state === "active"}
|
||||
<Icon name="activity" size={12} />
|
||||
{:else if step.state === "blocked"}
|
||||
<Icon name="warning" size={12} />
|
||||
{/if}
|
||||
{step.label}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if model.isDebugPanelOpen}
|
||||
<div class="mt-3 grid gap-2 rounded-lg border border-border bg-surface-page px-3 py-2 text-[11px] text-text-muted sm:grid-cols-2 lg:grid-cols-5">
|
||||
<!-- ── Row 1: IDs ── -->
|
||||
@@ -595,7 +668,7 @@
|
||||
{#if msg.role === "user"}
|
||||
<!-- User bubble -->
|
||||
<div class="max-w-[75%] rounded-2xl bg-primary px-4 py-3 text-white shadow-sm">
|
||||
<p class="text-sm whitespace-pre-wrap leading-relaxed">{msg.text}</p>
|
||||
<p class="text-sm whitespace-pre-wrap leading-relaxed">{model.cleanVisibleMessageText(msg.text)}</p>
|
||||
{#if msg.attachment}
|
||||
<a
|
||||
href={`/api/storage/file?path=${encodeURIComponent(msg.attachment.file_path)}`}
|
||||
@@ -747,20 +820,46 @@
|
||||
<!-- Error -->
|
||||
{#if model.streamingState === "error" && model.error}
|
||||
<div class="flex justify-start">
|
||||
<div class="max-w-[80%] rounded-2xl border border-destructive-ring bg-destructive-light px-4 py-3" role="alert">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<svg class="w-5 h-5 text-destructive shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-destructive">Ошибка</span>
|
||||
<div class="w-full max-w-[86%] rounded-2xl border border-destructive-ring bg-destructive-light px-4 py-3" role="alert">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white text-destructive">
|
||||
<Icon name="warning" size={17} />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-semibold text-destructive">Агент не завершил ответ</div>
|
||||
<p class="mt-1 text-sm text-destructive">{model.error}</p>
|
||||
<div class="mt-3 grid gap-2 sm:flex sm:flex-wrap">
|
||||
<button
|
||||
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-destructive-ring bg-white px-3 py-1.5 text-xs font-semibold text-destructive transition hover:bg-destructive-light"
|
||||
onclick={() => model.retryLastUserMessage()}
|
||||
>
|
||||
<Icon name="refresh" size={13} />
|
||||
Повторить последний запрос
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border-strong bg-white px-3 py-1.5 text-xs font-semibold text-text transition hover:bg-surface-muted"
|
||||
onclick={() => model.checkLlmStatus()}
|
||||
>
|
||||
<Icon name="activity" size={13} />
|
||||
Проверить LLM
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border-strong bg-white px-3 py-1.5 text-xs font-semibold text-text transition hover:bg-surface-muted"
|
||||
onclick={copyDebugInfo}
|
||||
>
|
||||
<Icon name="copy" size={13} />
|
||||
Скопировать debug
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border-strong bg-white px-3 py-1.5 text-xs font-semibold text-text transition hover:bg-surface-muted"
|
||||
onclick={() => model.createConversation()}
|
||||
>
|
||||
<Icon name="plus" size={13} />
|
||||
Новый диалог
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-destructive">{model.error}</p>
|
||||
<button
|
||||
class="mt-3 rounded-lg border border-destructive-ring bg-white px-3 py-1.5 text-xs font-semibold text-destructive transition hover:bg-destructive-light"
|
||||
onclick={() => model.sendMessage(inputText)}
|
||||
>
|
||||
{$t.assistant?.retry || "Повторить"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface AgentChatModelOptions {
|
||||
export type AgentPhase = "ready" | "thinking" | "tooling" | "confirming" | "failed" | "offline";
|
||||
export type AgentPhaseTone = "success" | "warning" | "destructive" | "muted";
|
||||
export type ConfirmationRisk = "read" | "write" | "unknown";
|
||||
export type AgentProcessStepState = "done" | "active" | "blocked" | "idle";
|
||||
|
||||
export interface AgentQuickAction {
|
||||
id: "dashboards" | "migration" | "tasks" | "health";
|
||||
@@ -66,6 +67,12 @@ export interface AgentQuickAction {
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface AgentProcessStep {
|
||||
id: "context" | "reasoning" | "tools" | "confirmation" | "result";
|
||||
label: string;
|
||||
state: AgentProcessStepState;
|
||||
}
|
||||
|
||||
export class AgentChatModel {
|
||||
// ── Atoms ──────────────────────────────────────────────────────
|
||||
messages: AgentMessage[] = $state([]);
|
||||
@@ -185,6 +192,63 @@ export class AgentChatModel {
|
||||
? (this.conversationListItems.find((c) => c.id === this.currentConversationId)?.title || "Агент-чат")
|
||||
: "Агент-чат",
|
||||
);
|
||||
hasSilentStream = $derived(
|
||||
this.streamingState === "streaming" &&
|
||||
this.partialText.length === 0 &&
|
||||
this.partialTokens.length === 0 &&
|
||||
this.activeToolCalls.length === 0,
|
||||
);
|
||||
userFacingStatusTitle = $derived.by(() => {
|
||||
if (this.connectionState !== "connected") return "Нет соединения с агентом";
|
||||
if (this.streamingState === "awaiting_confirmation") return "Требуется подтверждение";
|
||||
if (this.streamingState === "error") return "Агент остановился";
|
||||
if (this.activeToolCalls.some((tool) => tool.status === "executing")) return "Выполняется инструмент";
|
||||
if (this.hasSilentStream) return "Ожидаю первый ответ";
|
||||
if (this.streamingState === "streaming") return "Агент отвечает";
|
||||
return "Готов к задаче";
|
||||
});
|
||||
userFacingStatusDetail = $derived.by(() => {
|
||||
if (this.connectionState !== "connected") return "Переподключитесь перед отправкой новой команды.";
|
||||
if (this.streamingState === "awaiting_confirmation") return "Проверьте действие и параметры перед продолжением.";
|
||||
if (this.streamingState === "error") return this.error || "Можно повторить последний запрос или начать новый диалог.";
|
||||
if (this.activeToolCalls.some((tool) => tool.status === "executing")) return "Действие уже передано инструменту, дождитесь результата.";
|
||||
if (this.hasSilentStream) return "Если upstream LLM не начнет отвечать, чат покажет восстановление вместо бесконечного ожидания.";
|
||||
if (this.streamingState === "streaming") return "Ответ поступает по стриму.";
|
||||
if (this.envId) return `Контекст готов: окружение ${this.envId}.`;
|
||||
return "Выберите окружение перед задачами, которые работают с Superset.";
|
||||
});
|
||||
processSteps = $derived.by((): AgentProcessStep[] => {
|
||||
const hasTools = this.activeToolCalls.length > 0 || this.messages.some((msg) => (msg.toolCalls || []).length > 0);
|
||||
const hasAssistantResult = this.messages.some((msg) => msg.role === "assistant" && msg.text.trim().length > 0);
|
||||
const failed = this.streamingState === "error";
|
||||
return [
|
||||
{
|
||||
id: "context",
|
||||
label: this.envId ? `Контекст: ${this.envId}` : "Контекст",
|
||||
state: this.envId ? "done" : failed ? "blocked" : "idle",
|
||||
},
|
||||
{
|
||||
id: "reasoning",
|
||||
label: this.hasSilentStream ? "Ожидание LLM" : "Понимание задачи",
|
||||
state: failed ? "blocked" : this.streamingState === "streaming" && !hasTools ? "active" : hasAssistantResult || hasTools ? "done" : "idle",
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
label: "Инструменты",
|
||||
state: this.activeToolCalls.some((tool) => tool.status === "executing") ? "active" : hasTools ? "done" : "idle",
|
||||
},
|
||||
{
|
||||
id: "confirmation",
|
||||
label: "Подтверждение",
|
||||
state: this.streamingState === "awaiting_confirmation" ? "active" : this.pendingThreadId ? "done" : "idle",
|
||||
},
|
||||
{
|
||||
id: "result",
|
||||
label: "Результат",
|
||||
state: failed ? "blocked" : this.streamingState === "idle" && hasAssistantResult ? "done" : "idle",
|
||||
},
|
||||
];
|
||||
});
|
||||
agentPhase = $derived.by((): AgentPhase => {
|
||||
if (this.connectionState !== "connected") return "offline";
|
||||
if (this.streamingState === "awaiting_confirmation") return "confirming";
|
||||
@@ -312,9 +376,11 @@ export class AgentChatModel {
|
||||
normalizeConversationTitle(rawTitle: unknown): string {
|
||||
const title = String(rawTitle ?? "")
|
||||
// Strip file upload markers (fallback for old conversations)
|
||||
.replace(/\n--- Uploaded file content ---[\s\S]*$/i, "")
|
||||
.replace(/(?:^|\n)--- Uploaded file content ---[\s\S]*$/i, "")
|
||||
// Strip pre-fetched data blocks
|
||||
.replace(/\s*\[PRE-FETCHED DATA[\s\S]*$/i, "")
|
||||
.replace(/^✅\s*/, "")
|
||||
.replace(/^(list|get|show|check|search)_\w+$/i, "Системное действие")
|
||||
// Strip trailing "Available dashboards..." suffix
|
||||
.replace(/\s+Available(?:\s+\S+)*$/i, "")
|
||||
// Collapse whitespace
|
||||
@@ -323,6 +389,25 @@ export class AgentChatModel {
|
||||
return title || "Новый диалог";
|
||||
}
|
||||
|
||||
cleanVisibleMessageText(rawText: unknown): string {
|
||||
return String(rawText ?? "")
|
||||
.replace(/(?:^|\n)--- Uploaded file content ---[\s\S]*$/i, "")
|
||||
.replace(/\n?\[PRE-FETCHED DATA[\s\S]*?```/i, "")
|
||||
.replace(/\n?\[PRE-FETCHED DATA[\s\S]*?<\/pre>/i, "")
|
||||
.replace(/\n?\[PRE-FETCHED DATA[\s\S]*$/i, "")
|
||||
.replace(/\n?\[\/PRE-FETCHED DATA\][\s\S]*$/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
async retryLastUserMessage(): Promise<void> {
|
||||
const lastUserMessage = [...this.messages].reverse().find((message) => message.role === "user");
|
||||
const text = this.cleanVisibleMessageText(lastUserMessage?.text || "");
|
||||
if (!text) return;
|
||||
this.streamingState = "idle";
|
||||
this.error = null;
|
||||
await this.sendMessage(text);
|
||||
}
|
||||
|
||||
async sendMessage(text: string, files?: File[]): Promise<void> {
|
||||
// Sync reactive values from the page component
|
||||
this.onBeforeSend?.();
|
||||
@@ -374,6 +459,7 @@ export class AgentChatModel {
|
||||
// processStream iterates all data events from the Gradio SSE stream.
|
||||
const streamDone = await Promise.race([
|
||||
this.streamProcessor.processStream(this._submission, convId),
|
||||
this._firstActivityTimeout(60_000),
|
||||
this.streamProcessor.streamCloseWatcher(this._client!, 180_000),
|
||||
new Promise<false>((resolve) => setTimeout(() => resolve(false), 180_000)),
|
||||
]);
|
||||
@@ -444,6 +530,21 @@ export class AgentChatModel {
|
||||
this.pendingRiskLevel = null;
|
||||
}
|
||||
|
||||
private async _firstActivityTimeout(ms: number): Promise<false> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const hasActivity =
|
||||
this.partialText.length > 0 ||
|
||||
this.partialTokens.length > 0 ||
|
||||
this.activeToolCalls.length > 0 ||
|
||||
this.streamingState === "awaiting_confirmation" ||
|
||||
Boolean(this.pendingThreadId);
|
||||
if (!hasActivity && this.streamingState === "streaming" && !this._userCancelled) {
|
||||
this.error = "Агент не начал отвечать за 60 секунд. Проверьте LLM/Gradio и попробуйте ещё раз.";
|
||||
return false;
|
||||
}
|
||||
return new Promise<false>(() => {});
|
||||
}
|
||||
|
||||
/** Commit in-progress streaming text + tool calls as an assistant message.
|
||||
* Called before switching conversations or creating a new one while streaming. */
|
||||
private _commitStreamingPartial(): void {
|
||||
@@ -528,6 +629,7 @@ export class AgentChatModel {
|
||||
// if Gradio stream closes without a clean completion event.
|
||||
const streamDone = await Promise.race([
|
||||
this.streamProcessor.processStream(this._submission, this.currentConversationId),
|
||||
this._firstActivityTimeout(60_000),
|
||||
this.streamProcessor.streamCloseWatcher(this._client!, 180_000),
|
||||
new Promise<false>((resolve) => setTimeout(() => resolve(false), 180_000)),
|
||||
]);
|
||||
@@ -660,7 +762,7 @@ export class AgentChatModel {
|
||||
id: (msg.message_id as string) ?? (msg.id as string) ?? "",
|
||||
conversation_id: msg.conversation_id as string ?? "",
|
||||
role: msg.role as string ?? "assistant",
|
||||
text: msg.text as string ?? "",
|
||||
text: this.cleanVisibleMessageText(msg.text),
|
||||
metadata: msg.metadata as StreamMetadata,
|
||||
toolCalls: (msg.tool_calls as ToolCall[]) ?? [],
|
||||
created_at: msg.created_at as string ?? "",
|
||||
@@ -800,7 +902,10 @@ export class AgentChatModel {
|
||||
private _loadFromStorage(conversationId: string): boolean {
|
||||
const data = this.storage.load(conversationId);
|
||||
if (data) {
|
||||
this.messages = data.messages;
|
||||
this.messages = data.messages.map((message) => ({
|
||||
...message,
|
||||
text: this.cleanVisibleMessageText(message.text),
|
||||
}));
|
||||
this.currentConversationId = data.conversationId;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -258,6 +258,45 @@ describe("AgentChatModel — Derived State", () => {
|
||||
expect(model.isConversationSidebarOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("user-facing status distinguishes silent stream from normal ready state", () => {
|
||||
model.envId = "ss-dev";
|
||||
expect(model.userFacingStatusTitle).toBe("Готов к задаче");
|
||||
expect(model.userFacingStatusDetail).toContain("ss-dev");
|
||||
|
||||
model.streamingState = "streaming";
|
||||
expect(model.hasSilentStream).toBe(true);
|
||||
expect(model.userFacingStatusTitle).toBe("Ожидаю первый ответ");
|
||||
expect(model.processSteps.find((step) => step.id === "reasoning")?.state).toBe("active");
|
||||
|
||||
model.error = "timeout";
|
||||
model.streamingState = "error";
|
||||
expect(model.userFacingStatusTitle).toBe("Агент остановился");
|
||||
expect(model.processSteps.find((step) => step.id === "result")?.state).toBe("blocked");
|
||||
});
|
||||
|
||||
it("normalizeConversationTitle removes service-only history noise", () => {
|
||||
expect(model.normalizeConversationTitle("--- Uploaded file content ---\nid,name")).toBe("Новый диалог");
|
||||
expect(model.normalizeConversationTitle("✅ list_environments")).toBe("Системное действие");
|
||||
expect(model.normalizeConversationTitle("Покажи доступные дашборды\n[PRE-FETCHED DATA]\nsecret")).toBe("Покажи доступные дашборды");
|
||||
});
|
||||
|
||||
it("retryLastUserMessage resends the last clean user request", async () => {
|
||||
const sendSpy = vi.spyOn(model, "sendMessage").mockResolvedValue(undefined);
|
||||
model.streamingState = "error";
|
||||
model.error = "timeout";
|
||||
model.messages = [
|
||||
{ id: "1", conversation_id: "c1", role: "user", text: "first", toolCalls: [], created_at: "" },
|
||||
{ id: "2", conversation_id: "c1", role: "assistant", text: "failed", toolCalls: [], created_at: "" },
|
||||
{ id: "3", conversation_id: "c1", role: "user", text: "run\n[PRE-FETCHED DATA]\nsecret", toolCalls: [], created_at: "" },
|
||||
];
|
||||
|
||||
await model.retryLastUserMessage();
|
||||
|
||||
expect(model.streamingState).toBe("idle");
|
||||
expect(model.error).toBeNull();
|
||||
expect(sendSpy).toHaveBeenCalledWith("run");
|
||||
});
|
||||
|
||||
it("conversationListItems sanitizes pre-fetched prompt noise for views", () => {
|
||||
model.conversations = [{
|
||||
id: "c1",
|
||||
@@ -269,6 +308,20 @@ describe("AgentChatModel — Derived State", () => {
|
||||
expect(model.conversationListItems[0].title).toBe("Покажи доступные дашборды");
|
||||
});
|
||||
|
||||
it("cleanVisibleMessageText strips hidden context from displayed messages", () => {
|
||||
const raw = [
|
||||
"Запусти обслуживание дашборда USA на 15 минут",
|
||||
"",
|
||||
"[PRE-FETCHED DATA — use this directly, do NOT call tools]",
|
||||
"Available dashboards in environment 'ss-dev'",
|
||||
"- USA Births Names (id: 15)",
|
||||
"[/PRE-FETCHED DATA]",
|
||||
].join("\n");
|
||||
|
||||
expect(model.cleanVisibleMessageText(raw)).toBe("Запусти обслуживание дашборда USA на 15 минут");
|
||||
expect(model.cleanVisibleMessageText("Проанализируй\n\n--- Uploaded file content ---\na,b")).toBe("Проанализируй");
|
||||
});
|
||||
|
||||
it("currentConversationTitle uses sanitized conversation title", () => {
|
||||
model.currentConversationId = "c1";
|
||||
model.conversations = [{
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
<!-- @REJECTED SvelteKit `load()` function rejected — Gradio client connection is browser-only (Client.connect) and cannot run during SSR. Inline sidebar toggle state in page (not delegated to model) rejected — ConversationList open/close is a cross-widget concern tied to the agent chat layout, not page-level UI. <Button> component from $lib/ui rejected for the sidebar toggle chrome control (line 102) — the w-6 h-12 shape + border-radius combination is too custom for Button's variant system; raw <button> is justified by @RATIONALE inline. -->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { browser } from "$app/environment";
|
||||
import { AgentChatModel } from "$lib/models/AgentChatModel.svelte.ts";
|
||||
import { Client } from "@gradio/client";
|
||||
import AgentChat from "$lib/components/agent/AgentChat.svelte";
|
||||
import ConversationList from "$lib/components/assistant/ConversationList.svelte";
|
||||
import { sidebarStore } from "$lib/stores/sidebar.svelte.js";
|
||||
import { auth } from "$lib/auth/store.svelte.js";
|
||||
import { environmentContextStore } from "$lib/stores/environmentContext.svelte.js";
|
||||
import { environmentContextStore, initializeEnvironmentContext } from "$lib/stores/environmentContext.svelte.js";
|
||||
import { Icon, Button } from "$lib/ui";
|
||||
|
||||
let model = $state<AgentChatModel | null>(null);
|
||||
@@ -25,9 +26,6 @@
|
||||
// $auth is Svelte auto-subscription syntax for stores with .subscribe()
|
||||
let currentUserId = $derived($auth.user?.id ?? "");
|
||||
let currentUserJwt = $derived($auth.token ?? "");
|
||||
// Get selected environment from the top-bar selector
|
||||
let currentEnvId = $derived($environmentContextStore.selectedEnvId ?? "");
|
||||
|
||||
// Guard against double initialization in dev HMR
|
||||
let _initialized = false;
|
||||
|
||||
@@ -35,7 +33,7 @@
|
||||
function syncModel(m: AgentChatModel): void {
|
||||
m.userId = $auth.user?.id ?? "";
|
||||
m.userJwt = $auth.token ?? "";
|
||||
m.envId = $environmentContextStore.selectedEnvId ?? "";
|
||||
m.envId = environmentContextStore.value?.selectedEnvId || (browser ? localStorage.getItem("selected_env_id") || "" : "");
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -62,6 +60,8 @@
|
||||
const gradioBaseUrl = `${window.location.origin}/api/agent/gradio`;
|
||||
|
||||
// Delegate connection to ConnectionManager for unified lifecycle
|
||||
initializeEnvironmentContext().then(() => syncModel(m));
|
||||
|
||||
Client.connect(gradioBaseUrl).then((client) => {
|
||||
Object.assign(m, { _client: client });
|
||||
m.connectionState = "connected";
|
||||
|
||||
Reference in New Issue
Block a user