Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions app/api/ai/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ interface AiRequestBody {
engagementId?: unknown;
engagementLabel?: unknown;
host?: unknown;
/** Opt-in web-augmented AI (OpenRouter `:online`); explicit per call. */
web?: unknown;
context?: {
port?: unknown;
protocol?: unknown;
Expand Down Expand Up @@ -135,10 +137,21 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Unknown task." }, { status: 400 });
}

// Opt-in web-augmented AI: OpenRouter runs a web search when the model id
// carries a `:online` suffix. Gated to OpenRouter (the only provider that
// understands it) and to an explicit per-call `web: true` from the client —
// this sends search queries derived from scan data off-host, so it is never
// ambient. The `:online` model is recorded verbatim in the usage ledger, so
// its extra cost shows up distinctly in /settings/usage.
const webRequested = parsed.body.web === true;
const useWeb = webRequested && cfg.provider === "openrouter";
const effectiveModel =
useWeb && !cfg.model.endsWith(":online") ? `${cfg.model}:online` : cfg.model;

const clientCfg = {
baseUrl: cfg.baseUrl,
apiKey: cfg.apiKey,
model: cfg.model,
model: effectiveModel,
};

// Best-effort usage ledger (analytics only — never breaks the AI response).
Expand All @@ -157,7 +170,7 @@ export async function POST(request: NextRequest) {
host: asStr(parsed.body.host) ?? null,
task: ledgerTask,
provider: cfg.provider,
model: cfg.model,
model: effectiveModel,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
costUsd: usage.costUsd,
Expand Down
101 changes: 73 additions & 28 deletions src/components/ExplainButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*/

import { useState } from "react";
import { Sparkles, Loader2, X } from "lucide-react";
import { Sparkles, Loader2, X, Globe } from "lucide-react";
import { useAiStatus } from "@/components/ai/useAiStatus";
import { AiContextPreview } from "@/components/ai/AiContextPreview";
import { AiErrorActions } from "@/components/ai/AiErrorActions";
Expand All @@ -35,14 +35,20 @@ export function ExplainButton(props: ExplainContext) {
const [error, setError] = useState<string | null>(null);
const [streaming, setStreaming] = useState(false);
const [open, setOpen] = useState(false);
// Whether the last/active run used the web-augmented (:online) path.
const [webMode, setWebMode] = useState(false);

// Hidden entirely unless the assistant is usable right now.
if (!status || !status.enabled) return null;

async function run() {
// Web search only works on OpenRouter (the `:online` suffix); hide elsewhere.
const canWeb = status.provider === "openrouter";

async function run(web = false) {
setOpen(true);
setText("");
setError(null);
setWebMode(web);
setStreaming(true);
try {
const res = await fetch("/api/ai", {
Expand All @@ -52,6 +58,7 @@ export function ExplainButton(props: ExplainContext) {
task: "explain",
engagementId: props.engagementId,
host: props.host ?? null,
web,
context: {
port: props.port,
protocol: props.protocol ?? null,
Expand Down Expand Up @@ -82,32 +89,65 @@ export function ExplainButton(props: ExplainContext) {

return (
<div>
<button
type="button"
onClick={run}
disabled={streaming}
title={`Explain with AI (${status.provider}${status.cloud ? " · cloud" : " · local"})`}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "4px 9px",
borderRadius: 5,
border: "1px solid var(--accent-border)",
background: "var(--accent-bg)",
color: "var(--accent)",
fontSize: 11.5,
fontWeight: 600,
cursor: streaming ? "wait" : "pointer",
}}
>
{streaming ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Sparkles size={12} />
<div style={{ display: "inline-flex", gap: 6 }}>
<button
type="button"
onClick={() => run(false)}
disabled={streaming}
title={`Explain with AI (${status.provider}${status.cloud ? " · cloud" : " · local"})`}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "4px 9px",
borderRadius: 5,
border: "1px solid var(--accent-border)",
background: "var(--accent-bg)",
color: "var(--accent)",
fontSize: 11.5,
fontWeight: 600,
cursor: streaming ? "wait" : "pointer",
}}
>
{streaming && !webMode ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Sparkles size={12} />
)}
{streaming && !webMode ? "Explaining…" : "Explain"}
</button>
{/* Opt-in web-augmented explain (OpenRouter :online) — sends search
queries derived from scan data off-host, so it's a deliberate,
separate click, never the default. */}
{canWeb && (
<button
type="button"
onClick={() => run(true)}
disabled={streaming}
title="Web-augmented explain: also runs a live web search for current CVEs/exploits via OpenRouter (:online). Sends scan-derived queries off-host; costs extra."
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "4px 9px",
borderRadius: 5,
border: "1px solid var(--border-strong)",
background: "var(--bg-2)",
color: "var(--fg-muted)",
fontSize: 11.5,
fontWeight: 600,
cursor: streaming ? "wait" : "pointer",
}}
>
{streaming && webMode ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Globe size={12} />
)}
{streaming && webMode ? "Searching…" : "Search web"}
</button>
)}
{streaming ? "Explaining…" : "Explain"}
</button>
</div>

{open && (
<div
Expand Down Expand Up @@ -135,7 +175,9 @@ export function ExplainButton(props: ExplainContext) {
>
<span>
AI EXPLANATION · {status.model}
{webMode ? ":online" : ""}
{status.cloud ? " · cloud" : " · local"}
{webMode ? " · 🌐 web" : ""}
</span>
<button
type="button"
Expand All @@ -154,7 +196,7 @@ export function ExplainButton(props: ExplainContext) {
</div>
<div style={{ padding: 10 }}>
{error ? (
<AiErrorActions error={error} onRetry={run} />
<AiErrorActions error={error} onRetry={() => run(webMode)} />
) : (
<div>
<Markdown text={text} />
Expand All @@ -172,6 +214,9 @@ export function ExplainButton(props: ExplainContext) {
>
AI-generated — verify before acting. The model only describes
the scan; it does not run anything.
{webMode
? " Web-augmented: a live web search ran for this; treat sources critically."
: ""}
</div>
)}
<AiContextPreview
Expand Down
3 changes: 2 additions & 1 deletion src/components/SuggestButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useAiStatus } from "@/components/ai/useAiStatus";
import { CopyButton } from "@/components/CopyButton";
import { AiContextPreview } from "@/components/ai/AiContextPreview";
import { AiErrorActions } from "@/components/ai/AiErrorActions";
import { Markdown } from "@/components/ai/Markdown";

interface Suggestion {
command: string;
Expand Down Expand Up @@ -237,7 +238,7 @@ export function SuggestButton(props: SuggestContext) {
lineHeight: 1.5,
}}
>
{s.why}
<Markdown text={s.why} />
</div>
)}
</div>
Expand Down
Loading