chore: dev dep @playwright/test
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
ac4cbdd9e6
commit
ee4a88e499
@@ -44,6 +44,9 @@ brings their own endpoint and their own spend. Blank key field = keep the stored
|
|||||||
|
|
||||||
- Analyst: any OpenAI-compatible base URL + key + model, plus optional extra JSON params.
|
- Analyst: any OpenAI-compatible base URL + key + model, plus optional extra JSON params.
|
||||||
A local endpoint such as `http://192.168.88.131:8888/v1` works; use key `EMPTY` when the server has no auth.
|
A local endpoint such as `http://192.168.88.131:8888/v1` works; use key `EMPTY` when the server has no auth.
|
||||||
|
For a reasoning model (Qwen3 on vLLM) put `{"chat_template_kwargs":{"enable_thinking":false}}` in **Extra params**:
|
||||||
|
with thinking on, the model spends the whole context budget reasoning and the report arrives empty.
|
||||||
|
Both model calls default to `max_tokens: 2048`; the same field overrides it.
|
||||||
- Jev: a TypeSafe API key from `console.typesafe.ai/keys`, model usually `jev-latest`.
|
- Jev: a TypeSafe API key from `console.typesafe.ai/keys`, model usually `jev-latest`.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { getAnalysis } from "@/lib/db";
|
||||||
|
import { ReportView } from "@/components/report-view";
|
||||||
|
import { ExportButtons } from "@/components/export-buttons";
|
||||||
|
import { VerdictSummary } from "@/components/verdict-summary";
|
||||||
|
|
||||||
|
export default async function Stored({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const u = await requireUser();
|
||||||
|
const a = getAnalysis((await params).id, u.id);
|
||||||
|
if (!a) notFound();
|
||||||
|
if (a.status === "running") redirect(`/app/${a.id}`); // still streaming elsewhere; rare, bounce once
|
||||||
|
return <main style={{ display: "grid", gap: "var(--qai-space-xl)" }}>
|
||||||
|
<h1 style={{ fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-2xl)", margin: 0 }}>{a.title}</h1>
|
||||||
|
{a.error && <p role="alert" style={{ color: "var(--qai-danger)" }}>{a.error}</p>}
|
||||||
|
{a.verdict && <VerdictSummary verdict={JSON.parse(a.verdict)} />}
|
||||||
|
{a.oul ? <><ExportButtons oul={a.oul} title={a.title} /><ReportView oul={a.oul} streaming={false} /></>
|
||||||
|
: <p className="label">No report was stored for this run.</p>}
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
+3
-2
@@ -1,9 +1,10 @@
|
|||||||
import { requireUser } from "@/lib/auth";
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { Rail } from "@/components/rail";
|
||||||
|
|
||||||
export default async function Console({ children }: { children: React.ReactNode }) {
|
export default async function Console({ children }: { children: React.ReactNode }) {
|
||||||
await requireUser();
|
const u = await requireUser();
|
||||||
return <div className="shell">
|
return <div className="shell">
|
||||||
<aside className="rail" aria-label="Previous analyses">qAI</aside>
|
<aside className="rail no-print" aria-label="Previous analyses"><Rail userId={u.id} /></aside>
|
||||||
<main className="main">{children}</main>
|
<main className="main">{children}</main>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1 +1,2 @@
|
|||||||
export default function NewAnalysis() { return <h1 style={{ fontFamily: "var(--qai-font-display)" }}>New analysis</h1>; }
|
import { Runner } from "@/components/runner";
|
||||||
|
export default function Run() { return <Runner />; }
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
export function ExportButtons({ oul, title }: { oul: string; title: string }) {
|
||||||
|
const copy = async () => {
|
||||||
|
const sql = [...document.querySelectorAll<HTMLElement>("#report pre.sql")].map((p) => p.textContent).join("\n\n");
|
||||||
|
await navigator.clipboard.writeText(`${title}\n\n${sql || oul}`);
|
||||||
|
};
|
||||||
|
return <div className="toolbar no-print" style={{ display: "flex", gap: "var(--qai-space-s)" }}>
|
||||||
|
<button className="btn" onClick={copy}>Copy</button>
|
||||||
|
<button className="btn" onClick={() => window.print()}>Export to PDF</button>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
import { rename, remove } from "@/lib/actions";
|
||||||
|
|
||||||
|
export function HistoryItem({ row }: { row: { id: string; title: string; status: string; created_at: number } }) {
|
||||||
|
// The server actions take (previousState, formData) for useActionState; a plain <form action> passes only
|
||||||
|
// formData, so bind the unused slot here rather than duplicating two single-arg actions.
|
||||||
|
const doRename = (fd: FormData) => rename(null, fd);
|
||||||
|
const doRemove = (fd: FormData) => remove(null, fd);
|
||||||
|
const when = new Date(row.created_at).toLocaleString(undefined,
|
||||||
|
{ month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||||
|
return <li className="card" style={{ padding: "var(--qai-space-md)", display: "grid", gap: "var(--qai-space-s)" }}>
|
||||||
|
<a href={`/app/${row.id}`} style={{ color: "var(--qai-ink)", textDecoration: "none", fontSize: "var(--qai-text-sm)" }}>
|
||||||
|
{row.title}{row.status !== "done" && <span className="pill" style={{ marginInlineStart: 8 }}>{row.status}</span>}
|
||||||
|
</a>
|
||||||
|
<div style={{ display: "flex", gap: "var(--qai-space-s)", alignItems: "center", flexWrap: "wrap" }}>
|
||||||
|
<span className="label">{when}</span>
|
||||||
|
<form action={doRename} style={{ display: "flex", gap: 4, marginLeft: "auto" }}>
|
||||||
|
<input type="hidden" name="id" value={row.id} />
|
||||||
|
<input className="field" name="title" aria-label="New name" defaultValue={row.title}
|
||||||
|
style={{ width: 90, padding: "4px 6px" }} />
|
||||||
|
<button className="btn" style={{ padding: "4px 8px" }}>Save</button>
|
||||||
|
</form>
|
||||||
|
{/* Delete is the only destructive action in the product: it never fires on a stray click. */}
|
||||||
|
<form action={doRemove} onSubmit={(e) => { if (!confirm("Delete this analysis?")) e.preventDefault(); }}>
|
||||||
|
<input type="hidden" name="id" value={row.id} />
|
||||||
|
<button className="btn" style={{ padding: "4px 8px" }}>Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</li>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { listAnalyses } from "@/lib/db";
|
||||||
|
import { HistoryItem } from "./history-item";
|
||||||
|
|
||||||
|
export function Rail({ userId }: { userId: string }) {
|
||||||
|
const rows = listAnalyses(userId);
|
||||||
|
return <>
|
||||||
|
<a href="/app" className="label" style={{ textDecoration: "none", color: "var(--qai-accent)" }}>qAI</a>
|
||||||
|
<p className="label">History · {rows.length}</p>
|
||||||
|
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "grid", gap: "var(--qai-space-s)" }}>
|
||||||
|
{rows.map((r) => <HistoryItem key={r.id} row={r} />)}
|
||||||
|
</ul>
|
||||||
|
<p className="label" style={{ marginTop: "auto" }}>
|
||||||
|
<a href="/settings" style={{ color: "var(--qai-ink-muted)" }}>Settings</a></p>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
import { Renderer } from "@openuidev/react-lang";
|
||||||
|
import { library } from "@/lib/report-ui";
|
||||||
|
|
||||||
|
export function ReportView({ oul, streaming }: { oul: string; streaming: boolean }) {
|
||||||
|
if (!oul) return null;
|
||||||
|
return <Renderer response={oul} library={library} isStreaming={streaming} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { readSse } from "@/lib/stream";
|
||||||
|
import { ReportView } from "./report-view";
|
||||||
|
import { ExportButtons } from "./export-buttons";
|
||||||
|
|
||||||
|
const STAGES = ["digesting", "proposing", "judging", "writing"] as const;
|
||||||
|
const LABEL: Record<string, string> = { digesting: "Reading the plan", proposing: "Asking for options",
|
||||||
|
judging: "Jev is scoring", writing: "Writing the report" };
|
||||||
|
|
||||||
|
export function Runner() {
|
||||||
|
const [xml, setXml] = useState(""); const [note, setNote] = useState("");
|
||||||
|
const [stage, setStage] = useState<string | null>(null);
|
||||||
|
const [oul, setOul] = useState(""); const [error, setError] = useState<string | null>(null);
|
||||||
|
const at = stage ? STAGES.indexOf(stage as (typeof STAGES)[number]) : -1;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
setError(null); setOul(""); setStage("digesting");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/analyze", { method: "POST",
|
||||||
|
headers: { "content-type": "application/json" }, body: JSON.stringify({ xml, note }) });
|
||||||
|
if (!res.ok || !res.body) { setError((await res.json().catch(() => null))?.error ?? `Request failed (${res.status}).`);
|
||||||
|
return setStage(null); }
|
||||||
|
for await (const e of readSse(res.body)) {
|
||||||
|
if (e.event === "stage") setStage(e.data.stage);
|
||||||
|
else if (e.event === "oul") setOul((s) => s + e.data.chunk);
|
||||||
|
else if (e.event === "error") setError(`${e.data.stage}: ${e.data.message}`);
|
||||||
|
else if (e.event === "done") { setStage(null); history.replaceState(null, "", `/app/${e.data.id}`); }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message || "The connection to the server dropped.");
|
||||||
|
setStage(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <section style={{ display: "grid", gap: "var(--qai-space-xl)", minWidth: 0 }}>
|
||||||
|
<h1 style={{ fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-display)",
|
||||||
|
letterSpacing: "-0.02em", margin: 0 }}>Paste a plan</h1>
|
||||||
|
<details className="card">
|
||||||
|
<summary style={{ cursor: "pointer", fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-sm)" }}>
|
||||||
|
Where do I get this XML?</summary>
|
||||||
|
<ol style={{ fontSize: "var(--qai-text-sm)", color: "var(--qai-ink-muted)", margin: "var(--qai-space-md) 0 0",
|
||||||
|
paddingLeft: "1.2em" }}>
|
||||||
|
<li>SSMS: press <strong>Ctrl+M</strong> (Include Actual Execution Plan), then run the query.</li>
|
||||||
|
<li>On the Execution Plan tab, right-click → <strong>Save As .sqlplan</strong>. Or right-click the statement → <strong>Showplan XML</strong> and copy the cell.</li>
|
||||||
|
<li>No SSMS: prefix the batch with <code>SET STATISTICS XML ON;</code> and copy the XML result cell.</li>
|
||||||
|
<li>Drop the file or paste its contents below. Only the digest leaves this container, and only to your own models.</li>
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
<div className="runner card" style={{ display: "grid", gap: "var(--qai-space-md)" }}>
|
||||||
|
<label className="label" htmlFor="xml">ShowPlan XML or .sqlplan contents</label>
|
||||||
|
<textarea className="field" id="xml" rows={7} spellCheck={false} value={xml}
|
||||||
|
onChange={(e) => setXml(e.target.value)} placeholder="<ShowPlanXML …>" />
|
||||||
|
<label className="label" htmlFor="note">Anything the plan cannot see? (optional)</label>
|
||||||
|
<input className="field" id="note" value={note} onChange={(e) => setNote(e.target.value)}
|
||||||
|
placeholder="slow since the 16.0.4085 migration" />
|
||||||
|
<div style={{ display: "flex", gap: "var(--qai-space-s)", alignItems: "center", flexWrap: "wrap" }}>
|
||||||
|
<button className="btn btn-primary" onClick={run} disabled={!xml.trim() || stage !== null}>Analyse</button>
|
||||||
|
<input type="file" accept=".sqlplan,.xml" aria-label="Load a .sqlplan file" style={{ fontSize: "var(--qai-text-xs)" }}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const f = e.target.files?.[0]; if (!f) return;
|
||||||
|
const buf = new Uint8Array(await f.arrayBuffer());
|
||||||
|
// SSMS saves UTF-16LE; File.text() decodes that as UTF-8 and fills the plan with NUL bytes.
|
||||||
|
const utf16 = buf[0] === 0xff && buf[1] === 0xfe;
|
||||||
|
setXml(new TextDecoder(utf16 ? "utf-16le" : "utf-8").decode(buf).replace(/^/, ""));
|
||||||
|
}} />
|
||||||
|
{at >= 0 && <span className="pill" aria-live="polite">{at + 1} of 4 · {LABEL[STAGES[at]]}</span>}
|
||||||
|
</div>
|
||||||
|
{error && <p role="alert" style={{ color: "var(--qai-danger)", margin: 0 }}>{error}</p>}
|
||||||
|
</div>
|
||||||
|
{oul && <ExportButtons oul={oul} title={note || "Query plan analysis"} />}
|
||||||
|
<ReportView oul={oul} streaming={stage !== null} />
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -25,7 +25,9 @@ export function SettingsForm({ action, view, step, account }: {
|
|||||||
{F("analyst_key", "API key", "", view.has_analyst
|
{F("analyst_key", "API key", "", view.has_analyst
|
||||||
? `Saved ${view.analyst_key_masked} — leave blank to keep it` : "Encrypted with APP_SECRET before it hits disk")}
|
? `Saved ${view.analyst_key_masked} — leave blank to keep it` : "Encrypted with APP_SECRET before it hits disk")}
|
||||||
{F("analyst_model", "Model name", view.analyst_model, "e.g. gpt-4o-mini, llama-3.3-70b")}
|
{F("analyst_model", "Model name", view.analyst_model, "e.g. gpt-4o-mini, llama-3.3-70b")}
|
||||||
{F("analyst_extra", "Extra params (JSON)", view.analyst_extra, 'Merged into the request body, e.g. {"temperature":0.2}')}
|
{F("analyst_extra", "Extra params (JSON)", view.analyst_extra,
|
||||||
|
'Merged into the request body, e.g. {"temperature":0.2}. Reasoning models stream thinking, not the answer: '
|
||||||
|
+ 'vLLM/Qwen needs {"chat_template_kwargs":{"enable_thinking":false}} or the report arrives empty.')}
|
||||||
</Group>
|
</Group>
|
||||||
<Group title="Jev — TypeSafe System One">
|
<Group title="Jev — TypeSafe System One">
|
||||||
{F("jev_key", "TypeSafe API key", "", view.has_jev
|
{F("jev_key", "TypeSafe API key", "", view.has_jev
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export function VerdictSummary({ verdict }: { verdict: any }) {
|
||||||
|
const win = verdict.order.find((o: any) => o.key === verdict.headline) ?? verdict.order[0];
|
||||||
|
return <section className="card" data-verdict>
|
||||||
|
<p className="label">Jev verdict · {verdict.source === "jev"
|
||||||
|
? `chosen, confidence ${verdict.jev_confidence.toFixed(2)}` : "composite order — Jev did not decide"}</p>
|
||||||
|
<p style={{ fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-lg)" }}>{win?.key}</p>
|
||||||
|
<p className="label">weights {Object.entries(verdict.weights as Record<string, number>)
|
||||||
|
.map(([k, v]) => `${k} ${v}`).join(" · ")}</p>
|
||||||
|
{Array.isArray(verdict.flags) && verdict.flags.length > 0
|
||||||
|
&& <p style={{ color: "var(--qai-warn)" }}>{verdict.flags.join(" · ")}</p>}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
Generated
+46
@@ -18,6 +18,7 @@
|
|||||||
"zod": "^4.6.5"
|
"zod": "^4.6.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.63.0",
|
||||||
"@types/node": "^24.13.6",
|
"@types/node": "^24.13.6",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -1283,6 +1284,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.63.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4563,6 +4580,35 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.63.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"zod": "^4.6.5"
|
"zod": "^4.6.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.63.0",
|
||||||
"@types/node": "^24.13.6",
|
"@types/node": "^24.13.6",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
Reference in New Issue
Block a user