diff --git a/src/app/dashboard/chat/chat-view.tsx b/src/app/dashboard/chat/chat-view.tsx index 345f0e1..10617fe 100644 --- a/src/app/dashboard/chat/chat-view.tsx +++ b/src/app/dashboard/chat/chat-view.tsx @@ -6,18 +6,33 @@ import { cn } from "@/lib/utils"; import { AnswerMarkdown } from "./answer-markdown"; import { UploadCard, type UploadState } from "./upload-card"; import type { ChartSpec } from "@/lib/reports/chart-spec"; +import { + SLASH_COMMANDS, + argHint, + expandSlashCommand, + matchCommands, + type SlashCommand, +} from "@/lib/chat/slash-commands"; +/** + * `text` is what the person sees; a slash command also carries the + * expanded `prompt` the model gets. `local` turns (/help) never reach + * the model. + */ type Turn = - | { role: "user"; text: string } - | { role: "assistant"; text: string; tools?: string[]; charts: ChartSpec[] } + | { role: "user"; text: string; prompt?: string; local?: boolean } + | { role: "assistant"; text: string; tools?: string[]; charts: ChartSpec[]; local?: boolean } | { role: "upload"; upload: UploadState }; -const SUGGESTIONS = [ - "Which vehicles need attention?", - "What's my projected profit on the Hills?", - "How are all my sites doing?", - "What's outstanding for verification?", -]; +/** Starter chips: the base reports. */ +const COMMAND_CHIPS = ["today", "portfolio", "problems", "findings"] + .map((name) => SLASH_COMMANDS.find((c) => c.name === name)) + .filter((c): c is SlashCommand => !!c); + +/** What a chip or menu pick puts in the input: args leave the cursor after a space. */ +function completion(c: SlashCommand) { + return c.args === "none" ? `/${c.name}` : `/${c.name} `; +} /** get_site_financials -> "site financials" */ function toolLabel(name: string) { @@ -35,6 +50,10 @@ export function ChatView() { const [busy, setBusy] = React.useState(false); const [toolRunning, setToolRunning] = React.useState(null); const [error, setError] = React.useState(null); + /** Inline, under the input: a bad slash command (e.g. /site with no site). */ + const [slashError, setSlashError] = React.useState(null); + const [menuIndex, setMenuIndex] = React.useState(0); + const [menuDismissed, setMenuDismissed] = React.useState(false); /** Set when the server reports the agent loop hit its iteration cap. Surfaced so the user knows the answer may be incomplete rather than mistaking a quiet stream end for a complete one. */ @@ -78,6 +97,25 @@ export function ChatView() { const q = question.trim(); if (!q || busy) return; + // Slash commands expand client-side. /help never calls the model. + const expanded = expandSlashCommand(q); + if (expanded?.kind === "error") { + setSlashError(expanded.message); + return; + } + setSlashError(null); + if (expanded?.kind === "help") { + setInput(""); + setTurns((prev) => [ + ...prev, + { role: "user", text: q, local: true }, + { role: "assistant", text: expanded.text, tools: [], charts: [], local: true }, + ]); + return; + } + const display = expanded?.kind === "prompt" ? expanded.display : q; + const prompt = expanded?.kind === "prompt" ? expanded.prompt : undefined; + setError(null); setBusy(true); setStoppedOnLimit(false); @@ -90,18 +128,21 @@ export function ChatView() { ...turns .filter( (t): t is Extract => - t.role !== "upload", + t.role !== "upload" && !t.local, ) - .map((t) => ({ role: t.role, text: t.text })), + .map((t) => ({ + role: t.role, + text: t.role === "user" ? t.prompt ?? t.text : t.text, + })), { role: "user" as const, - text: q, + text: prompt ?? q, ...(images.length > 0 ? { images } : {}), }, ]; setTurns((prev) => [ ...prev, - { role: "user", text: q }, + { role: "user", text: display, ...(prompt ? { prompt } : {}) }, { role: "assistant", text: "", tools: [], charts: [] }, ]); @@ -199,6 +240,24 @@ export function ChatView() { const empty = turns.length === 0; + /** Put text in the input and leave the cursor at its end. */ + const fillInput = React.useCallback((text: string) => { + setInput(text); + setSlashError(null); + setMenuDismissed(false); + setMenuIndex(0); + requestAnimationFrame(() => { + const el = inputRef.current; + if (!el) return; + el.focus(); + el.setSelectionRange(text.length, text.length); + }); + }, []); + + const menu = menuDismissed ? [] : matchCommands(input); + const menuOpen = menu.length > 0 && input.trimStart().startsWith("/"); + const activeIndex = Math.min(menuIndex, Math.max(0, menu.length - 1)); + const ACCEPT = "application/pdf,image/jpeg,image/png,image/webp"; const MAX_BYTES = 20 * 1024 * 1024; const fileRef = React.useRef(null); @@ -344,16 +403,28 @@ export function ChatView() {
- {SUGGESTIONS.map((s) => ( + {COMMAND_CHIPS.map((c) => ( ))}
+

+ Type / for every shortcut, e.g.{" "} + + , or just ask a question. +

)} @@ -477,8 +548,39 @@ export function ChatView() { e.preventDefault(); send(input); }} - className="mx-auto flex w-full max-w-3xl items-end gap-2 rounded-xl border border-border bg-card p-2 focus-within:border-[#F2B165]/50" + className="relative mx-auto flex w-full max-w-3xl items-end gap-2 rounded-xl border border-border bg-card p-2 focus-within:border-[#F2B165]/50" > + {menuOpen && ( +
+ {menu.map((c, i) => ( + + ))} +
+ )} setInput(e.target.value)} + onChange={(e) => { + setInput(e.target.value); + setSlashError(null); + setMenuDismissed(false); + setMenuIndex(0); + }} + role="combobox" + aria-expanded={menuOpen} + aria-controls="slash-menu" + aria-autocomplete="list" + aria-activedescendant={menuOpen ? `slash-${menu[activeIndex]?.name}` : undefined} onKeyDown={(e) => { + if (menuOpen) { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + const step = e.key === "ArrowDown" ? 1 : -1; + setMenuIndex((activeIndex + step + menu.length) % menu.length); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + setMenuDismissed(true); + return; + } + const picked = menu[activeIndex]; + const typed = input.trim(); + // Tab always completes. Enter completes a partial name; on a + // complete command it falls through and sends. + const partial = typed !== `/${picked.name}` && !/\s/.test(typed); + if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey && partial)) { + e.preventDefault(); + fillInput(completion(picked)); + return; + } + } // Enter sends; Shift+Enter makes a new line. While a // request is in flight the user can still draft the next // question — Enter is just ignored, the button stays @@ -535,7 +670,7 @@ export function ChatView() { } }} rows={1} - placeholder="Ask about sites, fleet, plant or orders…" + placeholder="Ask about sites, fleet, plant or orders — or type / for reports" className="max-h-40 flex-1 resize-none bg-transparent px-2 py-1.5 text-sm outline-none placeholder:text-muted-foreground" /> + {slashError && ( +

+ {slashError} +

+ )}

Reads live PocketBase data. Figures come from the dashboard's own calculations — the assistant never does its own arithmetic. diff --git a/src/lib/agent/tools.ts b/src/lib/agent/tools.ts index ba8c67d..ab27e4a 100644 --- a/src/lib/agent/tools.ts +++ b/src/lib/agent/tools.ts @@ -9,10 +9,6 @@ import { z } from "zod"; import { computeVehicleAttention, - computeSiteSpend, - computeSiteProjection, - resolveSiteStartDate, - parseDurationMonths, buildMonthlyIncomeVsExpenses, buildWeeklyIncomeVsExpenses, computeDeliveryNoteValue, diff --git a/src/lib/chat/slash-commands.test.ts b/src/lib/chat/slash-commands.test.ts new file mode 100644 index 0000000..64e3c67 --- /dev/null +++ b/src/lib/chat/slash-commands.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { SLASH_COMMANDS, expandSlashCommand, matchCommands } from "./slash-commands"; + +describe("slash commands", () => { + it("is not a command without a leading slash", () => { + expect(expandSlashCommand("how is the hills")).toBeNull(); + }); + it("expands /site with its argument and names the tool", () => { + const r = expandSlashCommand("/site Hills"); + expect(r).toMatchObject({ kind: "prompt", display: "/site Hills" }); + expect(r && r.kind === "prompt" && r.prompt).toMatch(/site_report/); + expect(r && r.kind === "prompt" && r.prompt).toMatch(/Hills/); + }); + it("requires a site for /site", () => { + expect(expandSlashCommand("/site")).toEqual({ kind: "error", message: "Which site? e.g. /site The Hills" }); + }); + it("/findings asks for all three reports and the My read section", () => { + const r = expandSlashCommand("/findings"); + const p = r && r.kind === "prompt" ? r.prompt : ""; + for (const t of ["portfolio_report", "problem_report", "improvement_report", "My read"]) expect(p).toContain(t); + }); + it("/help is answered locally", () => { + expect(expandSlashCommand("/help")?.kind).toBe("help"); + }); + it("unknown commands say so", () => { + expect(expandSlashCommand("/nope")).toEqual({ kind: "error", message: "Unknown command /nope — type /help" }); + }); + it("menu filters by prefix", () => { + expect(matchCommands("/p").map((c) => c.name)).toEqual(["portfolio", "problems", "plant", "purchasing"]); + }); + + // Beyond the plan's contract tests: + it("keeps the contract order", () => { + expect(SLASH_COMMANDS.map((c) => c.name)).toEqual([ + "site", "portfolio", "problems", "improvements", "findings", + "fuel", "plant", "workforce", "purchasing", "today", "help", + ]); + }); + it("scopes optional-site commands only when a site is given", () => { + const all = expandSlashCommand("/problems"); + const one = expandSlashCommand("/problems Kya Sands "); + expect(all && all.kind === "prompt" && all.prompt).not.toMatch(/"/); + expect(one).toMatchObject({ kind: "prompt", display: "/problems Kya Sands" }); + expect(one && one.kind === "prompt" && one.prompt).toContain('site "Kya Sands"'); + }); + it("ignores case in the command name and rejects arguments where none are taken", () => { + expect(expandSlashCommand("/PORTFOLIO")?.kind).toBe("prompt"); + expect(expandSlashCommand("/today Hills")).toEqual({ + kind: "error", + message: "/today takes no site — it covers every site.", + }); + }); + it("/help lists every command", () => { + const r = expandSlashCommand("/help"); + const text = r && r.kind === "help" ? r.text : ""; + for (const c of SLASH_COMMANDS) expect(text).toContain(`/${c.name}`); + }); + it("names the tools each report needs", () => { + const prompt = (s: string) => { + const r = expandSlashCommand(s); + return r && r.kind === "prompt" ? r.prompt : ""; + }; + expect(prompt("/today")).toMatch(/attendance_today.*problem_report.*rain_outlook.*delivery_note_queue/s); + expect(prompt("/plant")).toContain("plant_equipment_summary"); + expect(prompt("/workforce")).toContain("attendance_today"); + expect(prompt("/purchasing")).toContain("supplier_balances"); + expect(prompt("/fuel")).toContain("vehicles_needing_attention"); + expect(prompt("/improvements")).toContain("improvement_report"); + }); + it("the bare slash lists every command and a partial name narrows it", () => { + expect(matchCommands("/")).toHaveLength(SLASH_COMMANDS.length); + expect(matchCommands("/site Hills").map((c) => c.name)).toEqual(["site"]); + expect(matchCommands("hello")).toEqual([]); + }); +}); diff --git a/src/lib/chat/slash-commands.ts b/src/lib/chat/slash-commands.ts new file mode 100644 index 0000000..d9939dd --- /dev/null +++ b/src/lib/chat/slash-commands.ts @@ -0,0 +1,167 @@ +/** + * `/` shortcuts for Ask's base reports. Pure, client-side. + * + * A command expands into a precise prompt that names the tools to call, + * so "/site Hills" always produces the same report rather than whatever + * the model makes of a short phrase. /help is answered locally with no + * model call. The order of SLASH_COMMANDS is the menu order. + */ + +export interface SlashCommand { + /** "site" */ + name: string; + args: "none" | "optional-site" | "required-site"; + /** Shown in the menu. */ + summary: string; + prompt: (arg: string) => string; +} + +export type Expanded = + | { kind: "prompt"; display: string; prompt: string } + | { kind: "help"; text: string } + | { kind: "error"; message: string }; + +const forSite = (arg: string) => (arg ? ` for "${arg}"` : " across all sites"); +const withSite = (arg: string) => (arg ? ` with site "${arg}"` : ""); + +export const SLASH_COMMANDS: SlashCommand[] = [ + { + name: "site", + args: "required-site", + summary: "Site report: KPIs, burn, income vs expenses, fuel, payroll, POs, attention", + prompt: (s) => + `Produce a site report for "${s}". Call site_report for it, then problem_report for the same site. Lay it out as a report and place every chart.`, + }, + { + name: "portfolio", + args: "none", + summary: "All sites: contract vs spent vs projected, burn vs elapsed, at-risk sites", + prompt: () => + "Produce a portfolio report across all sites. Call portfolio_report. Lead with the sites at risk, show every site's status and projected profit in a table, and place every chart.", + }, + { + name: "problems", + args: "optional-site", + summary: "Everything wrong right now, ranked by severity", + prompt: (s) => + `Produce a problem report${forSite(s)}. Call problem_report${withSite(s)}. Group findings by severity, highest first, and link each one.`, + }, + { + name: "improvements", + args: "optional-site", + summary: "Room for improvement: data gaps and setup fixes", + prompt: (s) => + `Produce a room-for-improvement report${forSite(s)}. Call improvement_report${withSite(s)}. Group findings by severity, link each one, then write "My read" and "What I'd do next" as the prompt rules describe.`, + }, + { + name: "findings", + args: "none", + summary: "Ask's own findings: portfolio + problems + improvements, with My read", + prompt: () => + `Produce your own findings report. Call portfolio_report, problem_report and improvement_report. Then write "My read" and "What I'd do next" as the prompt rules describe.`, + }, + { + name: "fuel", + args: "optional-site", + summary: "Fleet fuel: litres, spend, alerts, wrong-unit limits", + prompt: (s) => + s + ? `Produce a fuel report for "${s}". Call site_report for "${s}" (its fuel KPI and daily fuel chart) and vehicles_needing_attention, and report only that site's vehicles. Keep fuel alerts separate from fuel limits set in the wrong unit, and link each vehicle.` + : "Produce a fleet fuel report across all sites. Call get_fuel_summary and vehicles_needing_attention. Keep fuel alerts separate from fuel limits set in the wrong unit, and link each vehicle.", + }, + { + name: "plant", + args: "optional-site", + summary: "Equipment hours and value (guarded meter diffs), rejected readings", + prompt: (s) => + `Produce a plant report${forSite(s)}. Call plant_equipment_summary${withSite(s)}. Show the totals, the items with the most value and hours, call out rejected hour-meter readings and items with no cost per hour, and place every chart.`, + }, + { + name: "workforce", + args: "optional-site", + summary: "Hours, cost, attendance today, missed scan-outs", + prompt: (s) => + `Produce a workforce report${forSite(s)}. Call top_workers${withSite(s)} and attendance_today${withSite(s)}. Show who is on site, who has not arrived, missed scan-outs, and the top workers by hours with their cost.`, + }, + { + name: "purchasing", + args: "optional-site", + summary: "POs, fulfilment, DN queue, supplier balances", + prompt: (s) => + `Produce a purchasing report${forSite(s)}. Call list_purchase_orders${withSite(s)}, problem_report${withSite(s)} (for overdue and unverified orders), delivery_note_queue and supplier_balances. Lead with what is overdue or stuck, then what is owed to whom.`, + }, + { + name: "today", + args: "none", + summary: "Daily brief: attendance, alerts, rain outlook, DN queue, imports", + prompt: () => + "Daily brief for today: call attendance_today, problem_report, rain_outlook and delivery_note_queue. Keep it to one screen.", + }, + { + name: "help", + args: "none", + summary: "List these commands", + prompt: () => "", + }, +]; + +const ARG_HINT: Record = { + none: "", + "optional-site": "[site]", + "required-site": "", +}; + +export function argHint(c: SlashCommand): string { + return ARG_HINT[c.args]; +} + +function helpText(): string { + const lines = SLASH_COMMANDS.map( + (c) => `- **/${c.name}${argHint(c) ? ` ${argHint(c)}` : ""}** — ${c.summary}`, + ); + return [ + "**Shortcuts**", + "", + ...lines, + "", + "`[site]` is optional; `` is required. A site can be its name or project code, e.g. `/site The Hills` or `/problems C26/09`.", + ].join("\n"); +} + +/** Split "/name rest of it " into ["name", "rest of it"]. */ +function parse(input: string): { name: string; arg: string } | null { + const t = input.trim(); + if (!t.startsWith("/")) return null; + const m = t.slice(1).match(/^(\S*)\s*([\s\S]*)$/); + return { name: (m?.[1] ?? "").toLowerCase(), arg: (m?.[2] ?? "").replace(/\s+/g, " ").trim() }; +} + +/** null = not a command (send as a normal question). */ +export function expandSlashCommand(input: string): Expanded | null { + const p = parse(input); + if (!p) return null; + const cmd = SLASH_COMMANDS.find((c) => c.name === p.name); + if (!cmd) return { kind: "error", message: `Unknown command /${p.name} — type /help` }; + if (cmd.name === "help") return { kind: "help", text: helpText() }; + if (cmd.args === "required-site" && !p.arg) { + return { kind: "error", message: `Which site? e.g. /${cmd.name} The Hills` }; + } + if (cmd.args === "none" && p.arg) { + return { kind: "error", message: `/${cmd.name} takes no site — it covers every site.` }; + } + return { + kind: "prompt", + display: p.arg ? `/${cmd.name} ${p.arg}` : `/${cmd.name}`, + prompt: cmd.prompt(p.arg), + }; +} + +/** Commands for the menu. While an argument is being typed, only that command. */ +export function matchCommands(partial: string): SlashCommand[] { + const p = parse(partial); + if (!p) return []; + const typingArg = /^\/\S+\s/.test(partial.trimStart()); + return typingArg + ? SLASH_COMMANDS.filter((c) => c.name === p.name) + : SLASH_COMMANDS.filter((c) => c.name.startsWith(p.name)); +}