0cb5e41faf
- Fix: an errored session alerted on every update, and Clear alerts only dropped it in memory, so its next update (or a reload) brought the alert straight back. An error now alerts on the transition into error only; cleared sessions are remembered (agenthub.pet.dismissedErrors) and stay quiet until they recover and fail anew. - Quips, greetings, notifications and Ask answers move to i18n (EN/VI); Ask understands Vietnamese keywords and names the sessions it counts. - The pet goes by the worn pet's name; click it in the panel to rename (agenthub.pet.names, synced to the account). - Bubbles name the session they are about, with an Open button (never a resume); a burst of the same event coalesces into one count. - Moments: a new session waves, a finish hops, a subagent peeks, a wait taps, an error flinches — on Sagi, Codex sprites and still images. Idle fidgets, a once-per-session greeting, a "Needs you" list, an amber waiting dot. - Pat (heart button, Ask, or stroking the pet) reacts on the pet itself, so it shows while the panel or chat is open. - Panel drops Run Claude / Activity / Sessions / Errored. - Ask hands off to a chat window (PetChat) on the default AI tool (Claude/Codex): transcript as bubbles, follow-ups and CLI menus through an off-screen attached terminal; no new server route. Verified on HEAD + this diff in a clean worktree: tsc -b, the full client suite (1334/1335; the one failure, ConversationView.follow, is flaky under load and passes 3/3 alone) and server me-prefs.test.js. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
215 lines
8.0 KiB
TypeScript
215 lines
8.0 KiB
TypeScript
/**
|
|
* @file intents.ts
|
|
* @description Sagi's local "Ask" brain. Matches a free-text question against a
|
|
* small set of intents answerable from cached dashboard status. Anything it
|
|
* can't answer becomes a handoff to the Run page (spawn a real `claude`).
|
|
* Understands English and Vietnamese keywords, names the sessions it reports
|
|
* when it knows them, and answers in the UI language (`settings:sagi.intent`).
|
|
* No network, no DOM - so it's fully unit-testable.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
/* =============================================================================
|
|
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
|
* =============================================================================
|
|
* **Purpose:** Sagi is the optional on-screen mascot assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
|
|
*
|
|
* ## Design constraints
|
|
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
|
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
|
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
|
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
|
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
|
*
|
|
* ## Remote data & SSH
|
|
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
|
*
|
|
* ## Internal dependencies
|
|
* - `./brain`
|
|
*
|
|
* ## Public surface
|
|
* - `AskResult` — exported API; see TSDoc on the symbol for behavior.
|
|
* - `AskContext` — exported API; see TSDoc on the symbol for behavior.
|
|
* - `statusSummary` — exported API; see TSDoc on the symbol for behavior.
|
|
* - `matchIntent` — exported API; see TSDoc on the symbol for behavior.
|
|
*
|
|
* ## Testing pointers
|
|
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
|
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
|
*
|
|
* ## Related docs
|
|
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
|
* - `docs/API.md` — REST reference.
|
|
* - `skills/file-headers/` — mandatory `@author` header policy.
|
|
* ============================================================================= */
|
|
/* -----------------------------------------------------------------------------
|
|
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
|
* -----------------------------------------------------------------------------
|
|
* **AskResult**
|
|
* Part of this module's public contract. Downstream imports should treat
|
|
* the signature and return type as stable unless release notes say otherwise.
|
|
* When behavior changes, update the `@file` overview and relevant tests.
|
|
*
|
|
* **matchIntent**
|
|
* Part of this module's public contract. Downstream imports should treat
|
|
* the signature and return type as stable unless release notes say otherwise.
|
|
* When behavior changes, update the `@file` overview and relevant tests.
|
|
*
|
|
* ----------------------------------------------------------------------------- */
|
|
|
|
import i18n from "../../i18n";
|
|
import { subjectLabel, type AttentionItem, type PetMoment, type SagiStatus } from "./brain";
|
|
import { pickLine } from "./quips";
|
|
|
|
export type AskResult =
|
|
{ kind: "answer"; text: string; moment?: PetMoment } | { kind: "handoff"; prompt: string };
|
|
|
|
/** What the answer may mention beyond the counts. */
|
|
export interface AskContext {
|
|
/** The pet's shown name. */
|
|
name?: string;
|
|
/** Errored and waiting sessions, named (`attentionOf`). */
|
|
attention?: ReadonlyArray<AttentionItem>;
|
|
}
|
|
|
|
const t = (key: string, vars?: Record<string, unknown>) =>
|
|
i18n.t(`settings:sagi.intent.${key}`, vars ?? {});
|
|
|
|
/** ": a, b, c" for up to three named sessions of a kind, "" when none known. */
|
|
function listOf(items: ReadonlyArray<AttentionItem> | undefined, kind: AttentionItem["kind"]) {
|
|
const names = (items ?? []).filter((i) => i.kind === kind).map(subjectLabel);
|
|
if (names.length === 0) return "";
|
|
const shown = names.slice(0, 3).join(", ");
|
|
return `: ${names.length > 3 ? `${shown}…` : shown}`;
|
|
}
|
|
|
|
/** "2 sessions live, 1 waiting on you" — the one-line status a greeting carries. */
|
|
export function statusSummary(status: SagiStatus): string {
|
|
const parts = [i18n.t("settings:sagi.greet.summary", { count: status.liveCount })];
|
|
if (status.waitingCount > 0)
|
|
parts.push(i18n.t("settings:sagi.greet.summaryWaiting", { count: status.waitingCount }));
|
|
if (status.errorCount > 0)
|
|
parts.push(i18n.t("settings:sagi.greet.summaryErrors", { count: status.errorCount }));
|
|
return parts.join(", ");
|
|
}
|
|
|
|
export function matchIntent(query: string, status: SagiStatus, ctx: AskContext = {}): AskResult {
|
|
const q = query.trim().toLowerCase().normalize("NFC");
|
|
const name = ctx.name || "Sagi";
|
|
if (!q) return { kind: "answer", text: t("empty") };
|
|
|
|
const has = (...words: string[]) => words.some((w) => q.includes(w));
|
|
// Whole-word match, for short words that hide inside longer ones ("hi" in "this").
|
|
const word = (...words: string[]) =>
|
|
words.some((w) => new RegExp(`(^|[^\\p{L}])${w}($|[^\\p{L}])`, "u").test(q));
|
|
|
|
if (has("help", "what can you", "what do you do", "trợ giúp", "làm được gì", "hướng dẫn")) {
|
|
return { kind: "answer", text: t("help", { name }) };
|
|
}
|
|
|
|
if (has("who are you", "your name", "bạn là ai", "tên gì", "tên bạn", "là ai")) {
|
|
return { kind: "answer", text: t("whoami", { name }), moment: "wave" };
|
|
}
|
|
|
|
if (has("thank", "thx", "cảm ơn", "cám ơn") || word("ty", "tks")) {
|
|
return { kind: "answer", text: pickLine("intent.thanks"), moment: "jump" };
|
|
}
|
|
|
|
if (
|
|
has("good boy", "good girl", "cute", "ngoan", "dễ thương", "xoa đầu", "vuốt ve", "giỏi quá") ||
|
|
word("pat")
|
|
) {
|
|
return { kind: "answer", text: pickLine("pat"), moment: "jump" };
|
|
}
|
|
|
|
// Errors first: "any failed runs" should report errors, not live count.
|
|
if (
|
|
has("error", "broke", "broken", "fail", "wrong", "crash", "lỗi", "hỏng", "thất bại", "sự cố")
|
|
) {
|
|
return {
|
|
kind: "answer",
|
|
text:
|
|
status.errorCount > 0
|
|
? t("errors", { count: status.errorCount, list: listOf(ctx.attention, "error") })
|
|
: t("errorsNone"),
|
|
};
|
|
}
|
|
|
|
if (
|
|
has(
|
|
"waiting",
|
|
"stuck",
|
|
"blocked",
|
|
"input",
|
|
"my turn",
|
|
"paused",
|
|
"chờ",
|
|
"đợi",
|
|
"kẹt",
|
|
"tới lượt",
|
|
"đến lượt"
|
|
)
|
|
) {
|
|
return {
|
|
kind: "answer",
|
|
text:
|
|
status.waitingCount > 0
|
|
? t("waiting", { count: status.waitingCount, list: listOf(ctx.attention, "waiting") })
|
|
: t("waitingNone"),
|
|
};
|
|
}
|
|
|
|
if (
|
|
has(
|
|
"running",
|
|
"active",
|
|
"live",
|
|
"going on",
|
|
"happening",
|
|
"in progress",
|
|
"đang chạy",
|
|
"đang làm",
|
|
"hoạt động"
|
|
)
|
|
) {
|
|
const tail = status.waitingCount > 0 ? t("liveTail", { count: status.waitingCount }) : "";
|
|
return {
|
|
kind: "answer",
|
|
text: status.liveCount > 0 ? t("live", { count: status.liveCount, tail }) : t("liveNone"),
|
|
};
|
|
}
|
|
|
|
if (
|
|
has(
|
|
"status",
|
|
"summary",
|
|
"overview",
|
|
"how are things",
|
|
"how's it",
|
|
"how is it",
|
|
"tình hình",
|
|
"trạng thái",
|
|
"tổng quan",
|
|
"thế nào",
|
|
"sao rồi"
|
|
)
|
|
) {
|
|
return {
|
|
kind: "answer",
|
|
text: t("status", {
|
|
live: status.liveCount,
|
|
waiting: status.waitingCount,
|
|
errors: status.errorCount,
|
|
conn: t(status.connected ? "connected" : "offline"),
|
|
}),
|
|
};
|
|
}
|
|
|
|
if (word("hi", "hello", "hey", "yo", "chào", "xin chào", "alo")) {
|
|
return { kind: "answer", text: t("hello", { summary: statusSummary(status) }), moment: "wave" };
|
|
}
|
|
|
|
return { kind: "handoff", prompt: query.trim() };
|
|
}
|