From e91873d214d0c13d19c7b90431dc9afc78b06699 Mon Sep 17 00:00:00 2001
From: PrashantUnity
Date: Wed, 17 Jun 2026 00:43:54 +0530
Subject: [PATCH 01/11] Race Conditon
---
src/website_profiling/crawl/crawler.py | 9 +-
.../crawl/fetchers/factory.py | 6 +-
.../integrations/google/keyword_enrich.py | 3 +-
src/website_profiling/llm/enrich.py | 12 ++-
src/website_profiling/llm/providers/openai.py | 5 +-
tests/test_browser_auth_from_session.py | 39 +++++++++
tests/test_crawler_deep.py | 42 ++++++++++
tests/test_keyword_intent.py | 25 ++++++
tests/test_llm_enrich_batches.py | 54 ++++++++++++
tests/test_llm_provider_openai.py | 63 ++++++++++++++
web/next.config.mjs | 5 ++
web/src/ReportShell.tsx | 18 +++-
web/src/components/chat/ChatSidebar.tsx | 49 ++++++-----
.../contentStudio/WriteStudioSidebar.tsx | 22 ++---
web/src/context/PortfolioContext.tsx | 20 ++++-
web/src/context/ReportContext.tsx | 6 ++
web/src/context/portfolioLoadPlan.test.ts | 18 ++++
web/src/context/portfolioLoadPlan.ts | 12 +++
web/src/context/reportContextTypes.ts | 2 +
web/src/hooks/usePortfolioWidget.ts | 9 +-
web/src/lib/appNav.ts | 61 ++++++++++++++
web/src/lib/chatUrlState.test.ts | 2 +
web/src/lib/chatUrlState.ts | 3 +-
web/src/routes.test.ts | 83 ++++++++++++++++---
web/src/routes.ts | 2 -
web/src/strings.json | 8 --
web/src/views/ContentStudio.tsx | 21 -----
27 files changed, 500 insertions(+), 99 deletions(-)
create mode 100644 tests/test_browser_auth_from_session.py
create mode 100644 tests/test_keyword_intent.py
create mode 100644 tests/test_llm_enrich_batches.py
create mode 100644 tests/test_llm_provider_openai.py
create mode 100644 web/src/context/portfolioLoadPlan.test.ts
create mode 100644 web/src/context/portfolioLoadPlan.ts
delete mode 100644 web/src/views/ContentStudio.tsx
diff --git a/src/website_profiling/crawl/crawler.py b/src/website_profiling/crawl/crawler.py
index c3b9076..1757e49 100644
--- a/src/website_profiling/crawl/crawler.py
+++ b/src/website_profiling/crawl/crawler.py
@@ -449,8 +449,15 @@ def crawl(
continue
futures.append(ex.submit(self.worker, url))
- if futures and self.queue.empty():
+ can_submit_more = (
+ not self.queue.empty()
+ and len(futures) < self.concurrency
+ and (len(self.results) + len(futures)) < self.max_pages
+ )
+ if futures and not can_submit_more:
# Block until at least one future completes instead of busy-polling.
+ # Covers both an empty frontier and a saturated worker pool; wait()
+ # returns immediately if a future is already done.
wait(futures, return_when=FIRST_COMPLETED)
remaining = []
diff --git a/src/website_profiling/crawl/fetchers/factory.py b/src/website_profiling/crawl/fetchers/factory.py
index 5f0bb8d..ff4f1d0 100644
--- a/src/website_profiling/crawl/fetchers/factory.py
+++ b/src/website_profiling/crawl/fetchers/factory.py
@@ -29,8 +29,10 @@ def _browser_auth_from_session(
headers[str(key)] = str(value)
credentials: Optional[dict[str, str]] = None
auth = getattr(session, "auth", None)
- if auth and auth[0]:
- credentials = {"username": str(auth[0]), "password": str(auth[1] or "")}
+ # requests also allows a callable auth handler; only basic (user, pass) tuples map here.
+ if isinstance(auth, (tuple, list)) and len(auth) >= 1 and auth[0]:
+ password = str(auth[1] or "") if len(auth) > 1 else ""
+ credentials = {"username": str(auth[0]), "password": password}
return headers, credentials
diff --git a/src/website_profiling/integrations/google/keyword_enrich.py b/src/website_profiling/integrations/google/keyword_enrich.py
index f592051..49a2310 100644
--- a/src/website_profiling/integrations/google/keyword_enrich.py
+++ b/src/website_profiling/integrations/google/keyword_enrich.py
@@ -73,7 +73,8 @@ def _normalize_kw(kw: str) -> str:
# ── Intent ────────────────────────────────────────────────────────────────────
def classify_intent(kw: str, brand_name: str = "") -> str:
- if brand_name and brand_name.lower().split()[0] in kw.lower():
+ brand_tokens = brand_name.lower().split()
+ if brand_tokens and brand_tokens[0] in kw.lower():
return "navigational"
if QUESTION_STARTS.match(kw):
return "informational"
diff --git a/src/website_profiling/llm/enrich.py b/src/website_profiling/llm/enrich.py
index b2f068b..1b0d4d8 100644
--- a/src/website_profiling/llm/enrich.py
+++ b/src/website_profiling/llm/enrich.py
@@ -12,6 +12,7 @@
import pandas as pd
from ..analysis.text import normalize_fingerprint_text
+from ..console_io import console_print
from ..llm_config import llm_is_enabled
from .base import get_llm_client
from .prompts import (
@@ -147,8 +148,11 @@ def _one(item: tuple[str, dict[str, Any]]) -> tuple[str, dict[str, Any], dict[st
if workers <= 1 or len(pending) <= 1:
for item in pending:
- _, payload, result = _one(item)
- apply_batch(payload, result)
+ try:
+ _, payload, result = _one(item)
+ apply_batch(payload, result)
+ except Exception as exc: # noqa: BLE001 - one batch failing must not abort the rest
+ console_print(f" LLM enrichment batch failed: {exc}", flush=True)
return
with ThreadPoolExecutor(max_workers=workers) as pool:
@@ -157,8 +161,8 @@ def _one(item: tuple[str, dict[str, Any]]) -> tuple[str, dict[str, Any], dict[st
try:
_, payload, result = future.result()
apply_batch(payload, result)
- except Exception:
- pass
+ except Exception as exc: # noqa: BLE001 - one batch failing must not abort the rest
+ console_print(f" LLM enrichment batch failed: {exc}", flush=True)
def _call_cached(
diff --git a/src/website_profiling/llm/providers/openai.py b/src/website_profiling/llm/providers/openai.py
index 29170eb..583e592 100644
--- a/src/website_profiling/llm/providers/openai.py
+++ b/src/website_profiling/llm/providers/openai.py
@@ -38,7 +38,10 @@ def complete_json(self, system: str, user: str) -> dict[str, Any]:
r = client.post(url, headers=headers, json=payload)
r.raise_for_status()
data = r.json()
- content = data["choices"][0]["message"]["content"]
+ choice = (data.get("choices") or [{}])[0]
+ content = (choice.get("message") or {}).get("content")
+ if content is None:
+ raise RuntimeError("OpenAI response contained no content.")
return parse_json_response(content if isinstance(content, str) else json.dumps(content))
def chat_with_tools(
diff --git a/tests/test_browser_auth_from_session.py b/tests/test_browser_auth_from_session.py
new file mode 100644
index 0000000..7215e23
--- /dev/null
+++ b/tests/test_browser_auth_from_session.py
@@ -0,0 +1,39 @@
+"""Regression tests for mapping a requests Session's auth onto browser context options.
+
+`_browser_auth_from_session` must not assume `session.auth` is a 2-tuple — requests
+also allows a callable auth handler and 1-element credentials.
+"""
+from __future__ import annotations
+
+import requests
+
+from website_profiling.crawl.fetchers.factory import _browser_auth_from_session
+
+
+def test_none_session_returns_empty_options() -> None:
+ assert _browser_auth_from_session(None) == ({}, None)
+
+
+def test_basic_two_tuple_auth_maps_to_credentials() -> None:
+ session = requests.Session()
+ session.headers["X-Custom"] = "1"
+ session.auth = ("user", "secret")
+ headers, credentials = _browser_auth_from_session(session)
+ assert credentials == {"username": "user", "password": "secret"}
+ assert headers.get("X-Custom") == "1"
+ # User-Agent is intentionally filtered out (the browser sets its own).
+ assert "User-Agent" not in headers
+
+
+def test_single_element_auth_defaults_password_to_empty() -> None:
+ session = requests.Session()
+ session.auth = ("user-only",)
+ _, credentials = _browser_auth_from_session(session)
+ assert credentials == {"username": "user-only", "password": ""}
+
+
+def test_callable_auth_handler_is_ignored_without_raising() -> None:
+ session = requests.Session()
+ session.auth = lambda request: request # e.g. HTTPDigestAuth / custom handler
+ _, credentials = _browser_auth_from_session(session)
+ assert credentials is None
diff --git a/tests/test_crawler_deep.py b/tests/test_crawler_deep.py
index 1f8c58e..6ad95a4 100644
--- a/tests/test_crawler_deep.py
+++ b/tests/test_crawler_deep.py
@@ -180,6 +180,48 @@ def _slow_worker(url):
assert len(df) == 2
+def test_crawl_waits_when_pool_saturated_instead_of_busy_spinning(monkeypatch):
+ # Regression: when every worker slot is busy AND the frontier still has URLs,
+ # the loop must block on wait() rather than busy-spin. We record the queue size
+ # at each wait() call; on the buggy version wait() only fired once the queue was
+ # empty (qsize == 0), so observing a wait() with a non-empty queue proves the fix.
+ import time
+ import website_profiling.crawl.crawler as mod
+
+ monkeypatch.setattr(
+ "website_profiling.crawl.sitemap.discover_sitemap_urls",
+ lambda *_a, **_k: [],
+ )
+ c = mod.Crawler(
+ start_url="https://site.com",
+ ignore_robots=True,
+ use_wappalyzer=False,
+ concurrency=2,
+ max_pages=4,
+ )
+ for path in ("/a", "/b", "/c"):
+ c.queue.put(f"https://site.com{path}")
+
+ qsizes_at_wait: list[int] = []
+ real_wait = mod.wait
+
+ def _recording_wait(fs, **kwargs):
+ qsizes_at_wait.append(c.queue.qsize())
+ return real_wait(fs, **kwargs)
+
+ monkeypatch.setattr(mod, "wait", _recording_wait)
+
+ def _slow_worker(url):
+ time.sleep(0.02)
+ return {"url": url, "status": 200, "content_type": "text/html", "title": "ok", "outlinks": 0}
+
+ monkeypatch.setattr(c, "worker", _slow_worker)
+ df = c.crawl(show_progress=False)
+
+ assert len(df) == 4
+ assert any(q > 0 for q in qsizes_at_wait)
+
+
def test_crawl_runs_and_handles_done_futures(monkeypatch):
import website_profiling.crawl.crawler as mod
diff --git a/tests/test_keyword_intent.py b/tests/test_keyword_intent.py
new file mode 100644
index 0000000..7b220b8
--- /dev/null
+++ b/tests/test_keyword_intent.py
@@ -0,0 +1,25 @@
+"""Regression tests for keyword intent classification.
+
+`classify_intent` must not raise when the brand name is empty or whitespace-only.
+"""
+from __future__ import annotations
+
+import pytest
+
+from website_profiling.integrations.google.keyword_enrich import classify_intent
+
+_VALID = {"navigational", "informational", "transactional", "commercial"}
+
+
+@pytest.mark.parametrize("brand", ["", " ", "\t\n"])
+def test_blank_or_whitespace_brand_does_not_raise(brand: str) -> None:
+ # Whitespace-only brand is truthy but splits to [], which used to IndexError.
+ assert classify_intent("some search query", brand_name=brand) in _VALID
+
+
+def test_brand_token_match_is_navigational() -> None:
+ assert classify_intent("acme login", brand_name="Acme") == "navigational"
+
+
+def test_no_brand_falls_through_to_other_intents() -> None:
+ assert classify_intent("how to bake bread") == "informational"
diff --git a/tests/test_llm_enrich_batches.py b/tests/test_llm_enrich_batches.py
new file mode 100644
index 0000000..8b885df
--- /dev/null
+++ b/tests/test_llm_enrich_batches.py
@@ -0,0 +1,54 @@
+"""Regression tests for `_run_llm_batches` failure handling.
+
+A failing batch must not abort the run, and the failure must be logged (observable)
+in BOTH the sequential and the concurrent code paths — they used to diverge
+(sequential propagated; concurrent silently swallowed).
+"""
+from __future__ import annotations
+
+import pytest
+
+from website_profiling.llm import enrich
+
+
+class _BoomClient:
+ """LLM client whose every call fails."""
+
+ def complete_json(self, system: str, user: str) -> dict:
+ raise RuntimeError("api unavailable")
+
+
+def test_sequential_path_logs_and_continues_on_failure(
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
+) -> None:
+ monkeypatch.setattr(enrich, "_llm_concurrency", lambda _cfg: 1)
+ applied: list = []
+ enrich._run_llm_batches(
+ _BoomClient(),
+ "task",
+ "system",
+ [{"k": 1}],
+ {},
+ lambda payload, result: applied.append(result),
+ )
+ out = capsys.readouterr().out
+ assert "LLM enrichment batch failed" in out
+ assert applied == [] # nothing applied, but no exception escaped
+
+
+def test_concurrent_path_logs_and_continues_on_failure(
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
+) -> None:
+ monkeypatch.setattr(enrich, "_llm_concurrency", lambda _cfg: 4)
+ applied: list = []
+ enrich._run_llm_batches(
+ _BoomClient(),
+ "task",
+ "system",
+ [{"k": 1}, {"k": 2}], # >1 batch + workers>1 -> concurrent path
+ {},
+ lambda payload, result: applied.append(result),
+ )
+ out = capsys.readouterr().out
+ assert out.count("LLM enrichment batch failed") >= 1
+ assert applied == []
diff --git a/tests/test_llm_provider_openai.py b/tests/test_llm_provider_openai.py
new file mode 100644
index 0000000..e46e91f
--- /dev/null
+++ b/tests/test_llm_provider_openai.py
@@ -0,0 +1,63 @@
+"""Regression tests for the OpenAI JSON-completion client.
+
+`complete_json` must defensively handle a 200 response whose body lacks the
+expected ``choices``/``message`` structure instead of raising KeyError/IndexError.
+"""
+from __future__ import annotations
+
+import sys
+import types
+
+import pytest
+
+from website_profiling.llm.providers.openai import OpenAIClient
+
+
+class _FakeResponse:
+ def __init__(self, payload: dict) -> None:
+ self._payload = payload
+
+ def raise_for_status(self) -> None:
+ return None
+
+ def json(self) -> dict:
+ return self._payload
+
+
+class _FakeClient:
+ def __init__(self, payload: dict) -> None:
+ self._payload = payload
+
+ def __call__(self, *args, **kwargs): # httpx.Client(...) constructor
+ return self
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args) -> bool:
+ return False
+
+ def post(self, *args, **kwargs) -> _FakeResponse:
+ return _FakeResponse(self._payload)
+
+
+def _install_fake_httpx(monkeypatch: pytest.MonkeyPatch, payload: dict) -> None:
+ fake = types.ModuleType("httpx")
+ fake.Client = _FakeClient(payload)
+ monkeypatch.setitem(sys.modules, "httpx", fake)
+
+
+def test_complete_json_missing_choices_raises_clean_error(monkeypatch: pytest.MonkeyPatch) -> None:
+ _install_fake_httpx(monkeypatch, {}) # no "choices"
+ client = OpenAIClient({"llm_api_key": "sk-test"})
+ with pytest.raises(RuntimeError, match="no content"):
+ client.complete_json("system", "user")
+
+
+def test_complete_json_parses_content_on_well_formed_response(monkeypatch: pytest.MonkeyPatch) -> None:
+ _install_fake_httpx(
+ monkeypatch,
+ {"choices": [{"message": {"content": '{"ok": true}'}}]},
+ )
+ client = OpenAIClient({"llm_api_key": "sk-test"})
+ assert client.complete_json("system", "user") == {"ok": True}
diff --git a/web/next.config.mjs b/web/next.config.mjs
index a19b5a1..dc804c2 100644
--- a/web/next.config.mjs
+++ b/web/next.config.mjs
@@ -22,6 +22,11 @@ const nextConfig = {
destination: '/dashboard?tab=charts',
permanent: false,
},
+ {
+ source: '/content-studio',
+ destination: '/write',
+ permanent: true,
+ },
];
},
};
diff --git a/web/src/ReportShell.tsx b/web/src/ReportShell.tsx
index 5983f44..ed1e508 100644
--- a/web/src/ReportShell.tsx
+++ b/web/src/ReportShell.tsx
@@ -31,13 +31,13 @@ import {
Globe2,
Contact2,
TextSearch,
- PenLine,
} from 'lucide-react';
import { UrlInspectorProvider } from './context/UrlInspectorContext';
import AppShell from './components/AppShell';
import { useReport } from './context/useReport';
import { strings } from './lib/strings';
import { canonicalDomainFromPayload, slugifyDomain } from './lib/domainSlug';
+import { REPORT_VIEW_IDS } from './lib/appNav';
import { pathSlugToViewId, viewIdToPathSlug, type ViewId } from './routes';
import { dispatchOpenIntegrations } from './lib/pipelineJobEvents';
import ReportShellSkeleton from './components/ReportShellSkeleton';
@@ -82,7 +82,6 @@ const Indexation = dynamic(() => import('./views/Indexation'), { loading: () =>
const Backlinks = dynamic(() => import('./views/Backlinks'), { loading: () => viewLoading() });
const Traffic = dynamic(() => import('./views/Traffic'), { loading: () => viewLoading() });
const KeywordsExplorer = dynamic(() => import('./views/KeywordsExplorer'), { loading: () => viewLoading() });
-const ContentStudio = dynamic(() => import('./views/ContentStudio'), { loading: () => viewLoading() });
const ExportReport = dynamic(() => import('./views/ExportReport'), { loading: () => viewLoading() });
const LogAnalyzer = dynamic(() => import('./views/LogAnalyzer'), { loading: () => viewLoading() });
const Subdomains = dynamic(() => import('./views/Subdomains'), { loading: () => viewLoading() });
@@ -146,9 +145,22 @@ const VIEW_CONFIG: ViewConfigEntry[] = [
{ id: 'backlinks', component: Backlinks as ComponentType, icon: Link2 },
{ id: 'traffic', component: Traffic as ComponentType, icon: BarChart2 },
{ id: 'keywords-explorer', component: KeywordsExplorer as ComponentType, icon: Key },
- { id: 'content-studio', component: ContentStudio as ComponentType, icon: PenLine },
];
+if (process.env.NODE_ENV !== 'production') {
+ const configIds = new Set(VIEW_CONFIG.map((entry) => entry.id));
+ for (const id of REPORT_VIEW_IDS) {
+ if (!configIds.has(id)) {
+ throw new Error(`ReportShell VIEW_CONFIG missing view id: ${id}`);
+ }
+ }
+ for (const entry of VIEW_CONFIG) {
+ if (!REPORT_VIEW_IDS.includes(entry.id)) {
+ throw new Error(`ReportShell VIEW_CONFIG has unknown view id: ${entry.id}`);
+ }
+ }
+}
+
const VIEWS = VIEW_CONFIG.map((v) => ({
...v,
label: strings.nav[v.id].label,
diff --git a/web/src/components/chat/ChatSidebar.tsx b/web/src/components/chat/ChatSidebar.tsx
index c287ee5..df06f00 100644
--- a/web/src/components/chat/ChatSidebar.tsx
+++ b/web/src/components/chat/ChatSidebar.tsx
@@ -2,23 +2,24 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import Link from 'next/link';
+import { usePathname } from 'next/navigation';
import {
ChevronLeft,
History,
- Home,
- Link as LinkIcon,
MessageSquarePlus,
PanelLeft,
- PenLine,
Settings,
- Terminal,
Trash2,
- TrendingUp,
} from 'lucide-react';
import AppLogo from '@/components/AppLogo';
import ThemeToggle from '@/components/ThemeToggle';
import type { ChatLayoutState } from '@/components/chat/ChatShell';
import { formatChatPropertyOption } from '@/lib/chatPropertyLabel';
+import {
+ CHAT_SIDEBAR_NAV_IDS,
+ isMiniNavLinkActive,
+ miniNavLinks,
+} from '@/lib/appNav';
import { strings } from '@/lib/strings';
const c = strings.components.chat;
@@ -46,13 +47,7 @@ export interface ChatSidebarProps extends ChatLayoutState {
loading?: boolean;
}
-const NAV_LINKS = [
- { href: '/home', label: c.navHome, icon: Home },
- { href: '/search-performance', label: c.navGsc, icon: TrendingUp },
- { href: '/links', label: c.navLinks, icon: LinkIcon },
- { href: '/pipeline', label: c.navPipeline, icon: Terminal },
- { href: '/write', label: strings.nav.write.label, icon: PenLine },
-] as const;
+const NAV_LINKS = miniNavLinks(CHAT_SIDEBAR_NAV_IDS);
function RailButton({
label,
@@ -115,6 +110,7 @@ export default function ChatSidebar({
toggle,
setExpanded,
}: ChatSidebarProps) {
+ const pathname = usePathname();
const [settingsOpen, setSettingsOpen] = useState(false);
const settingsRef = useRef(null);
@@ -276,17 +272,24 @@ export default function ChatSidebar({
diff --git a/web/src/components/contentStudio/WriteStudioSidebar.tsx b/web/src/components/contentStudio/WriteStudioSidebar.tsx
index 548f670..da0a17d 100644
--- a/web/src/components/contentStudio/WriteStudioSidebar.tsx
+++ b/web/src/components/contentStudio/WriteStudioSidebar.tsx
@@ -7,21 +7,20 @@ import {
ChevronLeft,
FileText,
History,
- Home,
- Link as LinkIcon,
- MessageSquarePlus,
PanelLeft,
- PenLine,
Plus,
Settings,
- Terminal,
Trash2,
- TrendingUp,
} from 'lucide-react';
import AppLogo from '@/components/AppLogo';
import ThemeToggle from '@/components/ThemeToggle';
import type { WriteLayoutState } from '@/components/contentStudio/WriteStudioShell';
import { formatChatPropertyOption } from '@/lib/chatPropertyLabel';
+import {
+ isMiniNavLinkActive,
+ miniNavLinks,
+ WRITE_SIDEBAR_NAV_IDS,
+} from '@/lib/appNav';
import { strings } from '@/lib/strings';
import type { ContentDraftListItem } from '@/types/contentStudio';
@@ -47,14 +46,7 @@ export interface WriteStudioSidebarProps extends WriteLayoutState {
readOnly?: boolean;
}
-const NAV_LINKS = [
- { href: '/home', label: c.navHome, icon: Home },
- { href: '/search-performance', label: c.navGsc, icon: TrendingUp },
- { href: '/links', label: c.navLinks, icon: LinkIcon },
- { href: '/pipeline', label: c.navPipeline, icon: Terminal },
- { href: '/chat', label: c.pageTitle, icon: MessageSquarePlus },
- { href: '/write', label: strings.nav.write.label, icon: PenLine },
-] as const;
+const NAV_LINKS = miniNavLinks(WRITE_SIDEBAR_NAV_IDS);
function RailButton({
label,
@@ -284,7 +276,7 @@ export default function WriteStudioSidebar({
-
-
-
-
-
-
+
+
+ {hasClientId ? strings.secrets.googleConfigured : strings.secrets.googleNotConfigured}
+
+
+ {strings.secrets.googleCredentialsHint}{' '}
+
+ {strings.secrets.pageTitle}
+
+
);
@@ -1048,7 +1000,7 @@ export default function GoogleIntegrationsPanel({
diff --git a/web/src/components/chat/ChatModelPicker.tsx b/web/src/components/chat/ChatModelPicker.tsx
index 9ed6a22..2e1df1f 100644
--- a/web/src/components/chat/ChatModelPicker.tsx
+++ b/web/src/components/chat/ChatModelPicker.tsx
@@ -263,7 +263,7 @@ export default function ChatModelPicker({
)}
setOpen(false)}
>
diff --git a/web/src/components/chat/ChatSidebar.tsx b/web/src/components/chat/ChatSidebar.tsx
index df06f00..f80ea3c 100644
--- a/web/src/components/chat/ChatSidebar.tsx
+++ b/web/src/components/chat/ChatSidebar.tsx
@@ -86,7 +86,7 @@ function SettingsMenu({ onClose }: { onClose: () => void }) {
diff --git a/web/src/components/contentStudio/WriteStudioSidebar.tsx b/web/src/components/contentStudio/WriteStudioSidebar.tsx
index da0a17d..4d90acf 100644
--- a/web/src/components/contentStudio/WriteStudioSidebar.tsx
+++ b/web/src/components/contentStudio/WriteStudioSidebar.tsx
@@ -85,7 +85,7 @@ function SettingsMenu({ onClose }: { onClose: () => void }) {
diff --git a/web/src/components/landing/LandingHero.tsx b/web/src/components/landing/LandingHero.tsx
new file mode 100644
index 0000000..285f537
--- /dev/null
+++ b/web/src/components/landing/LandingHero.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import Link from 'next/link';
+import { CheckCircle2, ChevronRight } from 'lucide-react';
+import LandingProductMock from '@/components/landing/LandingProductMock';
+import { strings } from '@/lib/strings';
+
+const vl = strings.views.landing;
+
+export default function LandingHero() {
+ return (
+
+
+
+
{vl.heroEyebrow}
+
+ {vl.heroTitle}
+
+
{vl.heroSubtitle}
+
+ {vl.heroBullets.map((bullet) => (
+ -
+
+ {bullet}
+
+ ))}
+
+
+
+ {vl.ctaRunAudit}
+
+
+
+ {vl.ctaDashboard}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/pipeline/PipelineContextBar.tsx b/web/src/components/pipeline/PipelineContextBar.tsx
new file mode 100644
index 0000000..5b99d87
--- /dev/null
+++ b/web/src/components/pipeline/PipelineContextBar.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import type { ReactNode } from 'react';
+import { Play } from 'lucide-react';
+import {
+ PIPELINE_SETTINGS_GROUPS,
+ type PipelineSettingsGroupId,
+} from '@/components/pipeline/pipelineSettingsGroups';
+import { SETTINGS_GROUP_ICONS } from '@/components/pipeline/pipelineUi';
+import type { PipelineNavId } from '@/lib/pipelineNav';
+import { strings } from '@/lib/strings';
+
+const s = strings.pipelineRunner;
+const groupLabels = s.settingsGroups;
+const groupDescriptions = s.settingsGroupDescriptions;
+
+function settingsGroupLabel(labelKey: string): string {
+ return (groupLabels as Record)[labelKey] ?? labelKey;
+}
+
+function settingsGroupDescription(labelKey: string): string {
+ return (groupDescriptions as Record)[labelKey] ?? '';
+}
+
+export interface PipelineContextBarProps {
+ activeNav: PipelineNavId;
+ headerExtra?: ReactNode;
+}
+
+export default function PipelineContextBar({ activeNav, headerExtra }: PipelineContextBarProps) {
+ const isRun = activeNav === 'run';
+ const group = !isRun
+ ? PIPELINE_SETTINGS_GROUPS.find((g) => g.id === activeNav)
+ : null;
+ const title = isRun
+ ? s.runTitle
+ : settingsGroupLabel(group?.labelKey ?? '');
+ const subtitle = isRun
+ ? s.runSubtitle
+ : settingsGroupDescription(group?.labelKey ?? '') || s.settingsSubtitle;
+ const Icon = isRun
+ ? Play
+ : SETTINGS_GROUP_ICONS[activeNav as PipelineSettingsGroupId];
+
+ return (
+
+
+
+
+
+ {title}
+
+
+ {subtitle}
+
+
+
+
+ {headerExtra ? {headerExtra}
: null}
+
+ );
+}
diff --git a/web/src/components/pipeline/PipelineSettingsPanel.tsx b/web/src/components/pipeline/PipelineSettingsPanel.tsx
index 94d73dd..a898185 100644
--- a/web/src/components/pipeline/PipelineSettingsPanel.tsx
+++ b/web/src/components/pipeline/PipelineSettingsPanel.tsx
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
+import Link from 'next/link';
import { Loader2, Save, X } from 'lucide-react';
import { strings, format } from '@/lib/strings';
import type { IntegrationToast, PipelineUnknownKey } from '@/types/api';
@@ -11,6 +12,7 @@ import {
partitionFieldsByTier,
} from '@/lib/pipelineConfigSchema';
import { LLM_CONFIG_SECTIONS, isLlmFieldVisible } from '@/lib/llmConfigSchema';
+import { isPipelineFieldVisibleOnPipeline } from '@/lib/secretsConfigSchema';
import OllamaModelPicker from '@/components/pipeline/OllamaModelPicker';
import SectionFieldLayout from './SectionFieldLayout';
import { usePipeline } from '@/context/PipelineContext';
@@ -28,6 +30,20 @@ import {
const s = strings.pipelineRunner;
+const SECRETS_BANNER_SECTIONS = new Set(['crawl', 'lighthouse', 'google', 'llm_provider']);
+
+function SecretsLinkBanner() {
+ return (
+
+ {strings.secrets.pipelineBanner}{' '}
+
+ {strings.secrets.pageTitle}
+
+ .
+
+ );
+}
+
type ConfigSection = (typeof PIPELINE_CONFIG_SECTIONS)[number];
type LlmSection = (typeof LLM_CONFIG_SECTIONS)[number];
@@ -227,7 +243,7 @@ export function PipelineSettingsSaveBar({ onSaved }: { onSaved?: () => void }) {
: s.settingsSubtitle;
return (
-
+
void }) {
variant="primary"
onClick={() => void handleSave()}
disabled={saveDisabled}
- className="shrink-0"
+ className="shrink-0 rounded-full"
>
{readOnly ? strings.app.readonlyBanner : saving ? s.saving : s.saveSettings}
@@ -344,11 +360,13 @@ export default function PipelineSettingsPanel({
if (pipelineSection) {
return (
<>
+ {SECRETS_BANNER_SECTIONS.has(sectionId) ? : null}
handlePipelineFieldChange(sectionId, key, value)}
+ fieldFilter={(key) => isPipelineFieldVisibleOnPipeline({ key })}
/>
{sectionId === 'crawl' ? : null}
>
@@ -359,23 +377,26 @@ export default function PipelineSettingsPanel({
if (llmSection) {
const isOllama = String(llmConfigState.llm_provider || 'none') === 'ollama';
return (
- setLlmField(key, value)}
- fieldFilter={(key) => isLlmFieldVisible(key, llmConfigState)}
- extra={
- llmSection.id === 'llm_provider' && isOllama ? (
- setLlmField('llm_model', v)}
- />
- ) : null
- }
- />
+ <>
+ {SECRETS_BANNER_SECTIONS.has(sectionId) ? : null}
+ setLlmField(key, value)}
+ fieldFilter={(key) => isLlmFieldVisible(key, llmConfigState)}
+ extra={
+ llmSection.id === 'llm_provider' && isOllama ? (
+ setLlmField('llm_model', v)}
+ />
+ ) : null
+ }
+ />
+ >
);
}
@@ -386,7 +407,7 @@ export default function PipelineSettingsPanel({
return null;
}
- const settingsCardClass = 'rounded-xl border border-default bg-brand-800/60 p-5 sm:p-6';
+ const settingsCardClass = 'rounded-2xl border border-muted/30 bg-[var(--chat-surface)] p-5 sm:p-6';
return (
@@ -437,13 +458,13 @@ export default function PipelineSettingsPanel({
) : (
{group.id === 'content-ai' ? (
-
+
{s.contentAiHint}
) : null}
{group.id === 'google' ? (
-
+
{s.googleGroupHint}
) : null}
diff --git a/web/src/components/secrets/SecretsContextBar.tsx b/web/src/components/secrets/SecretsContextBar.tsx
new file mode 100644
index 0000000..6efe205
--- /dev/null
+++ b/web/src/components/secrets/SecretsContextBar.tsx
@@ -0,0 +1,31 @@
+'use client';
+
+import { KeyRound } from 'lucide-react';
+import { SECRETS_SECTIONS, type SecretsNavId } from '@/lib/secretsConfigSchema';
+import { strings } from '@/lib/strings';
+
+const s = strings.secrets;
+
+export interface SecretsContextBarProps {
+ activeSection: SecretsNavId;
+}
+
+export default function SecretsContextBar({ activeSection }: SecretsContextBarProps) {
+ const section = SECRETS_SECTIONS.find((sec) => sec.id === activeSection);
+
+ return (
+
+
+
+
+
+ {section?.label ?? s.pageTitle}
+
+
+ {s.pageSubtitle}
+
+
+
+
+ );
+}
diff --git a/web/src/components/secrets/SecretsSettingsPanel.tsx b/web/src/components/secrets/SecretsSettingsPanel.tsx
new file mode 100644
index 0000000..f56fa7e
--- /dev/null
+++ b/web/src/components/secrets/SecretsSettingsPanel.tsx
@@ -0,0 +1,148 @@
+'use client';
+
+import Link from 'next/link';
+import ConfigField from '@/components/pipeline/ConfigField';
+import { SECRETS_SECTIONS, type SecretsField, type SecretsSection } from '@/lib/secretsConfigSchema';
+import { strings } from '@/lib/strings';
+import type { SecretsState } from '@/types/api';
+
+const s = strings.secrets;
+
+function envHintsForSection(section: SecretsSection, envHints: Record
) {
+ const names: string[] = [];
+ for (const field of section.fields) {
+ for (const envVar of field.envVars ?? []) {
+ if (envHints[envVar]) names.push(envVar);
+ }
+ }
+ return names;
+}
+
+function toConfigField(field: SecretsField) {
+ return {
+ key: field.key,
+ label: field.label,
+ type: field.type,
+ help: field.help,
+ placeholder: field.placeholder,
+ };
+}
+
+export interface SecretsSettingsPanelProps {
+ activeSection: SecretsSection['id'];
+ state: SecretsState;
+ envHints: Record;
+ disabled?: boolean;
+ onChange: (key: string, value: string | boolean) => void;
+}
+
+export default function SecretsSettingsPanel({
+ activeSection,
+ state,
+ envHints,
+ disabled,
+ onChange,
+}: SecretsSettingsPanelProps) {
+ const section = SECRETS_SECTIONS.find((sec) => sec.id === activeSection);
+ if (!section) return null;
+
+ const activeEnvHints = envHintsForSection(section, envHints);
+
+ return (
+
+ {activeEnvHints.length ? (
+
+ {s.envConfigured}: {activeEnvHints.join(', ')}
+
+ ) : null}
+
+
+ {section.fields.map((field) => {
+ if (field.key === 'google_service_account_json' && state.google_service_account_json_masked) {
+ return (
+
+
{field.label}
+ {field.help ? (
+
{field.help}
+ ) : null}
+
+
+ {s.serviceAccountSaved}
+
+
+ );
+ }
+
+ return (
+
onChange(field.key, value)}
+ />
+ );
+ })}
+
+
+ {section.id === 'google' ? (
+
+ {s.googleConnectHint}{' '}
+
+ {s.googleConnectLink}
+
+
+ ) : null}
+
+ {section.id === 'ai' ? (
+
+ {s.aiProviderHint}{' '}
+
+ {s.aiProviderLink}
+
+
+ ) : null}
+
+ );
+}
+
+export function SecretsSaveBar({
+ saving,
+ loading,
+ saveMsg,
+ readOnly,
+ onSave,
+}: {
+ saving: boolean;
+ loading: boolean;
+ saveMsg: string;
+ readOnly: boolean;
+ onSave: () => void;
+}) {
+ const saveFailed = saveMsg && !saveMsg.includes('saved');
+ return (
+
+
+ {saveMsg || s.saveHint}
+
+
+
+ );
+}
diff --git a/web/src/components/secrets/SecretsSidebar.tsx b/web/src/components/secrets/SecretsSidebar.tsx
new file mode 100644
index 0000000..48e1ed7
--- /dev/null
+++ b/web/src/components/secrets/SecretsSidebar.tsx
@@ -0,0 +1,253 @@
+'use client';
+
+import { useEffect, useRef, useState, type ReactNode } from 'react';
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+import {
+ ChevronLeft,
+ KeyRound,
+ Lock,
+ PanelLeft,
+ Plug,
+ Settings,
+ Sparkles,
+ type LucideIcon,
+} from 'lucide-react';
+import AppLogo from '@/components/AppLogo';
+import ThemeToggle from '@/components/ThemeToggle';
+import type { ChatLayoutState } from '@/components/chat/ChatShell';
+import {
+ SECRETS_SIDEBAR_NAV_IDS,
+ isMiniNavLinkActive,
+ miniNavLinks,
+} from '@/lib/appNav';
+import { SECRETS_SECTIONS, type SecretsNavId } from '@/lib/secretsConfigSchema';
+import { strings } from '@/lib/strings';
+
+const s = strings.secrets;
+const c = strings.components.chat;
+
+const NAV_LINKS = miniNavLinks(SECRETS_SIDEBAR_NAV_IDS);
+
+const SECTION_ICONS: Record = {
+ ai: Sparkles,
+ google: KeyRound,
+ integrations: Plug,
+ crawl: Lock,
+};
+
+export interface SecretsSidebarProps extends ChatLayoutState {
+ activeSection: SecretsNavId;
+ onSectionChange: (section: SecretsNavId) => void;
+}
+
+function RailButton({
+ label,
+ onClick,
+ children,
+ active,
+}: {
+ label: string;
+ onClick?: () => void;
+ children: ReactNode;
+ active?: boolean;
+}) {
+ return (
+
+ );
+}
+
+function SettingsMenu({ onClose }: { onClose: () => void }) {
+ return (
+
+
{c.settingsTitle}
+
+ Theme
+
+
+
+ {s.pipelineSettingsLink}
+
+
+ );
+}
+
+export default function SecretsSidebar({
+ activeSection,
+ onSectionChange,
+ expanded,
+ toggle,
+ setExpanded,
+}: SecretsSidebarProps) {
+ const pathname = usePathname();
+ const [settingsOpen, setSettingsOpen] = useState(false);
+ const settingsRef = useRef(null);
+
+ useEffect(() => {
+ if (!settingsOpen) return;
+ const onDocClick = (e: MouseEvent) => {
+ if (settingsRef.current && !settingsRef.current.contains(e.target as Node)) {
+ setSettingsOpen(false);
+ }
+ };
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') setSettingsOpen(false);
+ };
+ document.addEventListener('mousedown', onDocClick);
+ document.addEventListener('keydown', onKey);
+ return () => {
+ document.removeEventListener('mousedown', onDocClick);
+ document.removeEventListener('keydown', onKey);
+ };
+ }, [settingsOpen]);
+
+ const sectionList = (
+
+ {SECRETS_SECTIONS.map((section) => {
+ const Icon = SECTION_ICONS[section.id as SecretsNavId] ?? KeyRound;
+ const selected = activeSection === section.id;
+ return (
+ -
+
+
+ );
+ })}
+
+ );
+
+ if (!expanded) {
+ const ActiveIcon = SECTION_ICONS[activeSection] ?? KeyRound;
+ return (
+
+
+
+
+
+
setExpanded(true)}>
+
+
+
+
setExpanded(true)} active>
+
+
+
+
+
setSettingsOpen((v) => !v)}
+ active={settingsOpen}
+ >
+
+
+ {settingsOpen ? (
+
+ setSettingsOpen(false)} />
+
+ ) : null}
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/web/src/hooks/useSecrets.ts b/web/src/hooks/useSecrets.ts
new file mode 100644
index 0000000..8074cb2
--- /dev/null
+++ b/web/src/hooks/useSecrets.ts
@@ -0,0 +1,85 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import { apiUrl } from '@/lib/publicBase';
+import { buildInitialSecretsState } from '@/lib/secretsConfigSchema';
+import type { SecretsLoadResult, SecretsState } from '@/types/api';
+
+export function useSecrets() {
+ const [state, setState] = useState(buildInitialSecretsState);
+ const [envHints, setEnvHints] = useState>({});
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [saveMsg, setSaveMsg] = useState('');
+ const [loadError, setLoadError] = useState('');
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setLoadError('');
+ try {
+ const res = await fetch(apiUrl('/secrets'));
+ if (!res.ok) {
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
+ throw new Error(data.error || `HTTP ${res.status}`);
+ }
+ const data = (await res.json()) as SecretsLoadResult;
+ setState(data.state);
+ setEnvHints(data.envHints || {});
+ } catch (e) {
+ setLoadError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ const setField = useCallback((key: string, value: string | boolean) => {
+ setState((prev) => {
+ const next = { ...prev, [key]: value };
+ if (typeof value === 'string' && value && !value.startsWith('••••') && value !== '{configured}') {
+ delete next[`${key}_masked`];
+ }
+ return next;
+ });
+ }, []);
+
+ const save = useCallback(async () => {
+ setSaving(true);
+ setSaveMsg('');
+ try {
+ const res = await fetch(apiUrl('/secrets'), {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ state }),
+ });
+ const data = (await res.json().catch(() => ({}))) as SecretsLoadResult & { error?: string };
+ if (!res.ok) {
+ throw new Error(data.error || `HTTP ${res.status}`);
+ }
+ setState(data.state);
+ setEnvHints(data.envHints || {});
+ setSaveMsg('Secrets saved.');
+ return true;
+ } catch (e) {
+ setSaveMsg(e instanceof Error ? e.message : String(e));
+ return false;
+ } finally {
+ setSaving(false);
+ }
+ }, [state]);
+
+ return {
+ state,
+ envHints,
+ loading,
+ saving,
+ saveMsg,
+ loadError,
+ setField,
+ save,
+ reload: load,
+ };
+}
diff --git a/web/src/lib/appNav.ts b/web/src/lib/appNav.ts
index f0895f4..fb6b670 100644
--- a/web/src/lib/appNav.ts
+++ b/web/src/lib/appNav.ts
@@ -32,7 +32,7 @@ import {
import { strings } from '@/lib/strings';
import { viewIdToPathSlug, type ViewId } from '@/routes';
-export type NavItemId = ViewId | 'pipeline' | 'chat' | 'write';
+export type NavItemId = ViewId | 'pipeline' | 'secrets' | 'chat' | 'write';
export interface AppNavItem {
id: NavItemId;
@@ -79,6 +79,7 @@ const NAV_DESCRIPTIONS: Partial> = {
traffic: 'GA4 sessions & users',
'keywords-explorer': 'Keyword research & expansion',
pipeline: 'Crawl a site and build a report',
+ secrets: 'API keys and credentials',
chat: 'Ask questions about this audit',
write: 'Draft content from audit data',
};
@@ -123,6 +124,15 @@ const PIPELINE_NAV: AppNavItem = {
description: NAV_DESCRIPTIONS.pipeline,
};
+const SECRETS_NAV: AppNavItem = {
+ id: 'secrets',
+ label: strings.nav.secrets.label,
+ section: strings.nav.secrets.section,
+ icon: Key,
+ hrefPath: '/secrets',
+ description: NAV_DESCRIPTIONS.secrets,
+};
+
const CHAT_NAV: AppNavItem = {
id: 'chat',
label: strings.nav.chat.label,
@@ -151,6 +161,7 @@ export const APP_NAV_ITEMS: AppNavItem[] = [
description: NAV_DESCRIPTIONS[id],
})),
PIPELINE_NAV,
+ SECRETS_NAV,
WRITE_NAV,
CHAT_NAV,
];
@@ -161,7 +172,7 @@ export const REPORT_VIEW_IDS: ViewId[] = VIEW_NAV.map(({ id }) => id);
export const APP_NAV_SECTIONS = [...new Set(APP_NAV_ITEMS.map((item) => item.section))];
/** Routes with their own app pages — not resolved by `pathSlugToViewId`. */
-export const STANDALONE_NAV_IDS = ['pipeline', 'chat', 'write'] as const satisfies readonly NavItemId[];
+export const STANDALONE_NAV_IDS = ['pipeline', 'secrets', 'chat', 'write'] as const satisfies readonly NavItemId[];
export type StandaloneNavId = (typeof STANDALONE_NAV_IDS)[number];
@@ -199,6 +210,7 @@ export const CHAT_SIDEBAR_NAV_IDS = [
'search-performance',
'links',
'pipeline',
+ 'secrets',
'write',
] as const satisfies readonly NavItemId[];
@@ -207,11 +219,17 @@ export const WRITE_SIDEBAR_NAV_IDS = [
'search-performance',
'links',
'pipeline',
+ 'secrets',
'chat',
'write',
] as const satisfies readonly NavItemId[];
+export const SECRETS_SIDEBAR_NAV_IDS = WRITE_SIDEBAR_NAV_IDS;
+
+export const PIPELINE_SIDEBAR_NAV_IDS = WRITE_SIDEBAR_NAV_IDS;
+
export function isMiniNavLinkActive(href: string, pathname: string): boolean {
+ if (href === '/secrets') return pathname.startsWith('/secrets');
if (href === '/write') return pathname.startsWith('/write');
if (href === '/chat') return pathname.startsWith('/chat');
if (href === '/pipeline') return pathname.startsWith('/pipeline');
@@ -219,7 +237,7 @@ export function isMiniNavLinkActive(href: string, pathname: string): boolean {
}
export function navHref(item: AppNavItem, trailingQuery: string): string {
- if (item.id === 'home' || item.id === 'pipeline' || item.id === 'chat' || item.id === 'write') {
+ if (item.id === 'home' || item.id === 'pipeline' || item.id === 'secrets' || item.id === 'chat' || item.id === 'write') {
return item.hrefPath;
}
const raw = trailingQuery.startsWith('?') ? trailingQuery.slice(1) : trailingQuery;
@@ -237,6 +255,9 @@ export function isNavItemActive(item: AppNavItem, pathname: string): boolean {
if (item.id === 'pipeline') {
return pathname === '/pipeline' || pathname.startsWith('/pipeline/');
}
+ if (item.id === 'secrets') {
+ return pathname === '/secrets' || pathname.startsWith('/secrets/');
+ }
if (item.id === 'chat') {
return pathname === '/chat' || pathname.startsWith('/chat/');
}
diff --git a/web/src/lib/llmConfigSchema.ts b/web/src/lib/llmConfigSchema.ts
index daaff06..23cb9f5 100644
--- a/web/src/lib/llmConfigSchema.ts
+++ b/web/src/lib/llmConfigSchema.ts
@@ -14,7 +14,7 @@ export const LLM_CONFIG_SECTIONS = [
label: 'Enable AI insights',
type: 'bool',
defaultValue: false,
- help: 'Uses the provider below during report generation. Configure API keys here or via environment variables.',
+ help: 'Uses the provider below during report generation. API keys are managed on the Secrets page or via environment variables.',
},
{
key: 'llm_provider',
@@ -26,6 +26,7 @@ export const LLM_CONFIG_SECTIONS = [
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Google Gemini' },
{ value: 'anthropic', label: 'Anthropic Claude' },
+ { value: 'groq', label: 'Groq' },
{ value: 'ollama', label: 'Ollama (local)' },
],
},
@@ -34,7 +35,7 @@ export const LLM_CONFIG_SECTIONS = [
label: 'Model',
type: 'text',
defaultValue: '',
- placeholder: 'e.g. gpt-4o-mini, gemini-2.0-flash, claude-3-5-haiku-latest, llama3.2',
+ placeholder: 'e.g. gpt-4o-mini, gemini-2.0-flash, claude-3-5-haiku-latest, llama-3.3-70b-versatile, llama3.2',
help: 'Leave blank to use provider default.',
},
{
@@ -49,7 +50,7 @@ export const LLM_CONFIG_SECTIONS = [
label: 'API key',
type: 'secret',
defaultValue: '',
- help: 'Optional when OPENAI_API_KEY, GEMINI_API_KEY, or ANTHROPIC_API_KEY is set in the environment. Stored only in the database, not in saved audit settings files.',
+ help: 'Optional when OPENAI_API_KEY, GEMINI_API_KEY, ANTHROPIC_API_KEY, or GROQ_API_KEY is set in the environment. Stored only in the database, not in saved audit settings files.',
},
],
},
@@ -129,7 +130,7 @@ export function isLlmFieldVisible(
): boolean {
const provider = String(values.llm_provider || 'none');
if (key === 'llm_api_key') {
- return provider !== 'none' && provider !== 'ollama';
+ return false;
}
if (key === 'llm_base_url') {
return provider === 'ollama';
diff --git a/web/src/lib/pipelineConfigSchema.ts b/web/src/lib/pipelineConfigSchema.ts
index 9c780f6..f94f0c8 100644
--- a/web/src/lib/pipelineConfigSchema.ts
+++ b/web/src/lib/pipelineConfigSchema.ts
@@ -128,7 +128,7 @@ export const PIPELINE_CONFIG_SECTIONS: PipelineConfigSection[] = [
{
key: 'crawl_auth_password',
label: 'HTTP Basic auth password',
- type: 'text',
+ type: 'secret',
defaultValue: '',
},
{
@@ -141,7 +141,7 @@ export const PIPELINE_CONFIG_SECTIONS: PipelineConfigSection[] = [
{
key: 'crawl_cookies',
label: 'Cookie header value',
- type: 'text',
+ type: 'secret',
defaultValue: '',
},
{
@@ -496,7 +496,7 @@ export const PIPELINE_CONFIG_SECTIONS: PipelineConfigSection[] = [
{
key: 'google_rich_results_api_key',
label: 'Google Rich Results API key',
- type: 'text',
+ type: 'secret',
defaultValue: '',
help: 'Optional API key for Rich Results Test API when GSC OAuth is unavailable.',
placeholder: 'AIza…',
@@ -576,7 +576,7 @@ export const PIPELINE_CONFIG_SECTIONS: PipelineConfigSection[] = [
{
key: 'bing_webmaster_api_key',
label: 'Bing Webmaster API key',
- type: 'text',
+ type: 'secret',
defaultValue: '',
help: 'Optional. Fetches Bing backlinks summary on audit build and via Integrations sync.',
placeholder: 'Bing API key',
@@ -584,7 +584,7 @@ export const PIPELINE_CONFIG_SECTIONS: PipelineConfigSection[] = [
{
key: 'serp_api_key',
label: 'SerpAPI key (keyword SERP overlay)',
- type: 'text',
+ type: 'secret',
defaultValue: '',
help: 'Optional. Adds Estimated SERP competition signals to top keywords during enrichment.',
placeholder: 'SerpAPI key',
diff --git a/web/src/lib/pipelineNav.ts b/web/src/lib/pipelineNav.ts
new file mode 100644
index 0000000..84396da
--- /dev/null
+++ b/web/src/lib/pipelineNav.ts
@@ -0,0 +1,38 @@
+import type { PipelineSettingsGroupId } from '@/components/pipeline/pipelineSettingsGroups';
+
+export type PipelineNavId = 'run' | PipelineSettingsGroupId;
+
+export function pipelineNavFromSearchParams(searchParams: URLSearchParams): PipelineNavId {
+ const group = searchParams.get('group');
+ if (
+ group === 'crawl-report' ||
+ group === 'lighthouse' ||
+ group === 'keywords' ||
+ group === 'google' ||
+ group === 'content-ai' ||
+ group === 'advanced'
+ ) {
+ return group;
+ }
+ if (searchParams.get('tab') === 'settings') {
+ return 'crawl-report';
+ }
+ return 'run';
+}
+
+export function pipelineHrefForNav(
+ nav: PipelineNavId,
+ existingParams?: URLSearchParams,
+): string {
+ const params = new URLSearchParams(existingParams?.toString() ?? '');
+ params.delete('tab');
+ if (nav === 'run') {
+ params.delete('group');
+ } else {
+ params.set('group', nav);
+ }
+ const preset = params.get('preset');
+ if (preset) params.set('preset', preset);
+ const q = params.toString();
+ return q ? `/pipeline?${q}` : '/pipeline';
+}
diff --git a/web/src/lib/secretsConfigSchema.ts b/web/src/lib/secretsConfigSchema.ts
new file mode 100644
index 0000000..5c7d19b
--- /dev/null
+++ b/web/src/lib/secretsConfigSchema.ts
@@ -0,0 +1,193 @@
+/**
+ * Central registry for credentials managed on the /secrets page.
+ * Values are stored in llm_config, pipeline_config, or google_app_settings.
+ */
+import type { SecretsState } from '@/types/api';
+
+export type SecretsStorage = 'llm' | 'pipeline' | 'google';
+
+export type SecretsFieldType = 'text' | 'secret' | 'textarea';
+
+export interface SecretsField {
+ key: string;
+ label: string;
+ type: SecretsFieldType;
+ storage: SecretsStorage;
+ help?: string;
+ placeholder?: string;
+ envVars?: string[];
+}
+
+export interface SecretsSection {
+ id: string;
+ label: string;
+ fields: SecretsField[];
+}
+
+export type SecretsNavId = SecretsSection['id'];
+
+export const PIPELINE_SECRET_KEYS = new Set([
+ 'bing_webmaster_api_key',
+ 'serp_api_key',
+ 'google_rich_results_api_key',
+ 'crawl_auth_password',
+ 'crawl_cookies',
+]);
+
+export const SECRETS_SECTIONS: SecretsSection[] = [
+ {
+ id: 'ai',
+ label: 'AI providers',
+ fields: [
+ {
+ key: 'llm_api_key',
+ label: 'LLM API key',
+ type: 'secret',
+ storage: 'llm',
+ help: 'For the provider selected in Pipeline → Content & AI. Or set provider keys in the environment (see hints below).',
+ envVars: ['OPENAI_API_KEY', 'GEMINI_API_KEY', 'ANTHROPIC_API_KEY', 'GROQ_API_KEY'],
+ },
+ ],
+ },
+ {
+ id: 'google',
+ label: 'Google Cloud',
+ fields: [
+ {
+ key: 'google_client_id',
+ label: 'OAuth Client ID',
+ type: 'text',
+ storage: 'google',
+ placeholder: 'xxxxxxxx.apps.googleusercontent.com',
+ help: 'From Google Cloud Console → APIs & Services → Credentials.',
+ envVars: ['GOOGLE_CLIENT_ID'],
+ },
+ {
+ key: 'google_client_secret',
+ label: 'OAuth Client Secret',
+ type: 'secret',
+ storage: 'google',
+ placeholder: 'GOCSPX-...',
+ envVars: ['GOOGLE_CLIENT_SECRET'],
+ },
+ {
+ key: 'google_service_account_json',
+ label: 'Service account JSON',
+ type: 'textarea',
+ storage: 'google',
+ help: 'Paste the full JSON key file for service-account auth. Leave blank to keep the saved key.',
+ },
+ ],
+ },
+ {
+ id: 'integrations',
+ label: 'Third-party APIs',
+ fields: [
+ {
+ key: 'bing_webmaster_api_key',
+ label: 'Bing Webmaster API key',
+ type: 'secret',
+ storage: 'pipeline',
+ placeholder: 'Bing API key',
+ help: 'Fetches Bing backlinks summary on audit build and via Integrations sync.',
+ },
+ {
+ key: 'serp_api_key',
+ label: 'SerpAPI key',
+ type: 'secret',
+ storage: 'pipeline',
+ placeholder: 'SerpAPI key',
+ help: 'Adds estimated SERP competition signals to top keywords during enrichment.',
+ },
+ {
+ key: 'google_rich_results_api_key',
+ label: 'Google Rich Results API key',
+ type: 'secret',
+ storage: 'pipeline',
+ placeholder: 'AIza…',
+ help: 'Optional API key for Rich Results Test API when GSC OAuth is unavailable.',
+ },
+ ],
+ },
+ {
+ id: 'crawl',
+ label: 'Crawl authentication',
+ fields: [
+ {
+ key: 'crawl_auth_password',
+ label: 'HTTP Basic auth password',
+ type: 'secret',
+ storage: 'pipeline',
+ help: 'Used when crawling password-protected staging sites. Username is set on Pipeline → Crawl.',
+ },
+ {
+ key: 'crawl_cookies',
+ label: 'Cookie header value',
+ type: 'secret',
+ storage: 'pipeline',
+ help: 'Sent as the Cookie header on crawl requests.',
+ },
+ ],
+ },
+];
+
+export const ALL_SECRETS_KEYS = new Set(
+ SECRETS_SECTIONS.flatMap((s) => s.fields.map((f) => f.key)),
+);
+
+export const SECRETS_MASK_SENTINEL = '__MASKED__';
+
+export function isPipelineSecretKey(key: string): boolean {
+ return PIPELINE_SECRET_KEYS.has(key);
+}
+
+export function isPipelineFieldVisibleOnPipeline(field: { key: string }): boolean {
+ return !isPipelineSecretKey(field.key);
+}
+
+export function getSecretsFieldByKey(key: string): SecretsField | null {
+ for (const section of SECRETS_SECTIONS) {
+ const field = section.fields.find((f) => f.key === key);
+ if (field) return field;
+ }
+ return null;
+}
+
+export function isSecretsSecretKey(key: string): boolean {
+ const field = getSecretsFieldByKey(key);
+ return field?.type === 'secret' || field?.type === 'textarea';
+}
+
+/** Mask stored secret for GET responses. */
+export function maskSecretForClient(value: string | boolean | undefined): string {
+ if (!value || String(value).trim() === '') return '';
+ const s = String(value);
+ if (s.length <= 4) return '••••';
+ return `••••${s.slice(-4)}`;
+}
+
+export function buildInitialSecretsState(): SecretsState {
+ const out: SecretsState = {};
+ for (const section of SECRETS_SECTIONS) {
+ for (const f of section.fields) {
+ out[f.key] = '';
+ }
+ }
+ return out;
+}
+
+export function collectEnvHints(): Record {
+ const vars = new Set();
+ for (const section of SECRETS_SECTIONS) {
+ for (const field of section.fields) {
+ for (const envVar of field.envVars ?? []) {
+ vars.add(envVar);
+ }
+ }
+ }
+ const hints: Record = {};
+ for (const name of vars) {
+ hints[name] = Boolean(process.env[name]?.trim());
+ }
+ return hints;
+}
diff --git a/web/src/server/pipelineConfig.ts b/web/src/server/pipelineConfig.ts
index be51e86..0a043f1 100644
--- a/web/src/server/pipelineConfig.ts
+++ b/web/src/server/pipelineConfig.ts
@@ -13,6 +13,11 @@ import {
INTERNAL_PIPELINE_KEYS,
getFieldByKey,
} from '@/lib/pipelineConfigSchema';
+import {
+ isPipelineSecretKey,
+ maskSecretForClient,
+ SECRETS_MASK_SENTINEL,
+} from '@/lib/secretsConfigSchema';
import { getDataDir, withDb } from '@/server/db';
import type {
PipelineConfigLoadResult,
@@ -68,6 +73,14 @@ function buildDefaults(): PipelineConfigState {
export function applySchemaDefaults(parsedMap: Record): {
state: PipelineConfigState;
unknownKeys: PipelineUnknownKey[];
+} {
+ const { state, unknownKeys } = applySchemaDefaultsRaw(parsedMap);
+ return { state: maskPipelineSecretsForClient(state), unknownKeys };
+}
+
+function applySchemaDefaultsRaw(parsedMap: Record): {
+ state: PipelineConfigState;
+ unknownKeys: PipelineUnknownKey[];
} {
const state = buildDefaults();
const unknownKeys: PipelineUnknownKey[] = [];
@@ -95,6 +108,33 @@ export function applySchemaDefaults(parsedMap: Record): {
return { state, unknownKeys };
}
+export function maskPipelineSecretsForClient(state: PipelineConfigState): PipelineConfigState {
+ const out: PipelineConfigState = { ...state };
+ for (const key of Object.keys(out)) {
+ if (!isPipelineSecretKey(key)) continue;
+ const masked = maskSecretForClient(out[key]);
+ if (masked) {
+ out[key] = masked;
+ out[`${key}_masked`] = true;
+ }
+ }
+ return out;
+}
+
+function isMaskedSecretInput(
+ raw: string,
+ state: PipelineConfigState,
+ key: string,
+): boolean {
+ const trimmed = raw.trim();
+ return (
+ trimmed === '' ||
+ trimmed === SECRETS_MASK_SENTINEL ||
+ trimmed.startsWith('••••') ||
+ state[`${key}_masked`] === true
+ );
+}
+
export function serializeConfig(
state: PipelineConfigState,
unknownKeys: PipelineUnknownKey[] = [],
@@ -111,6 +151,7 @@ export function serializeConfig(
seenIds.add(section.id);
lines.push(`# --- ${section.label} ---`);
for (const f of section.fields) {
+ if (isPipelineSecretKey(f.key)) continue;
const v = state[f.key];
if (f.type === 'bool') {
lines.push(`${f.key} = ${v === true ? 'true' : 'false'}`);
@@ -185,9 +226,9 @@ export async function loadPipelineConfig(): Promise {
const { known, unknown } = await readPipelineConfigFromDb(client);
if (Object.keys(known).length > 0 || unknown.length > 0) {
- const { state, unknownKeys: schemaUnknown } = applySchemaDefaults(known);
+ const { state, unknownKeys: schemaUnknown } = applySchemaDefaultsRaw(known);
const allUnknown = filterUnknownKeys([...unknown, ...schemaUnknown]);
- return { state, unknownKeys: allUnknown, source: 'store' };
+ return { state: maskPipelineSecretsForClient(state), unknownKeys: allUnknown, source: 'store' };
}
const shadowPath = getShadowConfigPath();
@@ -196,8 +237,8 @@ export async function loadPipelineConfig(): Promise {
const raw = fs.readFileSync(shadowPath, 'utf8');
const parsed = parseInputTxt(raw);
if (Object.keys(parsed).length > 0) {
- const { state, unknownKeys } = applySchemaDefaults(parsed);
- return { state, unknownKeys: filterUnknownKeys(unknownKeys), source: 'legacy' };
+ const { state, unknownKeys } = applySchemaDefaultsRaw(parsed);
+ return { state: maskPipelineSecretsForClient(state), unknownKeys: filterUnknownKeys(unknownKeys), source: 'legacy' };
}
} catch {
/* fall through */
@@ -210,12 +251,17 @@ export async function loadPipelineConfig(): Promise {
export interface SavePipelineConfigOptions {
unknownKeys?: PipelineUnknownKey[];
+ preserveSecrets?: boolean;
}
export async function savePipelineConfig(
state: PipelineConfigState,
- { unknownKeys = [] }: SavePipelineConfigOptions = {},
+ { unknownKeys = [], preserveSecrets = true }: SavePipelineConfigOptions = {},
): Promise {
+ const existingKnown = preserveSecrets
+ ? (await withDb(async (client) => readPipelineConfigFromDb(client))).known
+ : {};
+
const entries: Record = {};
for (const section of PIPELINE_CONFIG_SECTIONS) {
for (const f of section.fields) {
@@ -225,6 +271,15 @@ export async function savePipelineConfig(
entries[f.key] = 'auto';
} else if (f.type === 'bool') {
entries[f.key] = v === true ? 'true' : 'false';
+ } else if (f.type === 'secret' || isPipelineSecretKey(f.key)) {
+ const raw = v == null ? '' : String(v).trim();
+ if (preserveSecrets && isMaskedSecretInput(raw, state, f.key) && existingKnown[f.key]) {
+ entries[f.key] = existingKnown[f.key];
+ } else if (raw && !raw.startsWith('••••')) {
+ entries[f.key] = raw;
+ } else {
+ entries[f.key] = existingKnown[f.key] || '';
+ }
} else {
entries[f.key] = v == null ? '' : String(v);
}
@@ -263,6 +318,16 @@ export async function savePipelineConfig(
}
});
- writeShadowFile(state, unknownKeys);
+ const shadowState: PipelineConfigState = { ...state };
+ for (const key of Object.keys(entries)) {
+ shadowState[key] = entries[key];
+ }
+ for (const key of Object.keys(shadowState)) {
+ if (isPipelineSecretKey(key)) {
+ delete shadowState[key];
+ delete shadowState[`${key}_masked`];
+ }
+ }
+ writeShadowFile(shadowState, unknownKeys);
return 'postgresql';
}
diff --git a/web/src/server/secrets.test.ts b/web/src/server/secrets.test.ts
new file mode 100644
index 0000000..ce8d9a4
--- /dev/null
+++ b/web/src/server/secrets.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import {
+ maskSecretForClient,
+ PIPELINE_SECRET_KEYS,
+} from '@/lib/secretsConfigSchema';
+import {
+ maskPipelineSecretsForClient,
+ serializeConfig,
+} from '@/server/pipelineConfig';
+
+describe('maskSecretForClient', () => {
+ it('masks values with last four characters', () => {
+ expect(maskSecretForClient('sk-test-secret-key')).toBe('••••-key');
+ });
+
+ it('returns empty for blank values', () => {
+ expect(maskSecretForClient('')).toBe('');
+ });
+});
+
+describe('maskPipelineSecretsForClient', () => {
+ it('masks pipeline secret keys', () => {
+ const masked = maskPipelineSecretsForClient({
+ bing_webmaster_api_key: 'bing-secret-1234',
+ start_url: 'https://example.com',
+ });
+ expect(masked.bing_webmaster_api_key).toBe('••••1234');
+ expect(masked.bing_webmaster_api_key_masked).toBe(true);
+ expect(masked.start_url).toBe('https://example.com');
+ });
+});
+
+describe('serializeConfig', () => {
+ it('omits pipeline secret keys from shadow output', () => {
+ const content = serializeConfig({
+ start_url: 'https://example.com',
+ bing_webmaster_api_key: 'should-not-appear',
+ serp_api_key: 'hidden',
+ crawl_auth_password: 'hidden',
+ crawl_cookies: 'hidden',
+ google_rich_results_api_key: 'hidden',
+ });
+ expect(content).toContain('start_url = https://example.com');
+ for (const key of PIPELINE_SECRET_KEYS) {
+ expect(content).not.toContain(`${key} =`);
+ }
+ });
+});
+
+describe('loadSecrets', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ it('aggregates masked secrets from stores', async () => {
+ vi.doMock('@/server/llmConfig', () => ({
+ loadLlmConfig: vi.fn().mockResolvedValue({
+ state: { llm_api_key: '••••cdef', llm_api_key_masked: true },
+ }),
+ }));
+ vi.doMock('@/server/pipelineConfig', () => ({
+ loadPipelineConfig: vi.fn().mockResolvedValue({
+ state: {
+ bing_webmaster_api_key: '••••1234',
+ bing_webmaster_api_key_masked: true,
+ },
+ unknownKeys: [],
+ }),
+ }));
+ vi.doMock('@/server/googleAppSettings', () => ({
+ loadGoogleAppSettings: vi.fn().mockResolvedValue({
+ clientId: 'client.apps.googleusercontent.com',
+ clientSecret: 'secret',
+ serviceAccount: null,
+ dateRangeDays: 28,
+ }),
+ }));
+
+ const { loadSecrets } = await import('@/server/secrets');
+ const result = await loadSecrets();
+ expect(result.state.llm_api_key).toBe('••••cdef');
+ expect(result.state.google_client_id).toBe('client.apps.googleusercontent.com');
+ expect(result.state.google_client_secret).toBe('••••cret');
+ expect(result.envHints).toBeTypeOf('object');
+ });
+});
diff --git a/web/src/server/secrets.ts b/web/src/server/secrets.ts
new file mode 100644
index 0000000..326cf58
--- /dev/null
+++ b/web/src/server/secrets.ts
@@ -0,0 +1,170 @@
+/**
+ * Aggregate secrets from llm_config, pipeline_config, and google_app_settings.
+ */
+import {
+ ALL_SECRETS_KEYS,
+ SECRETS_MASK_SENTINEL,
+ SECRETS_SECTIONS,
+ buildInitialSecretsState,
+ collectEnvHints,
+ getSecretsFieldByKey,
+ isSecretsSecretKey,
+ maskSecretForClient,
+} from '@/lib/secretsConfigSchema';
+import { loadGoogleAppSettings, saveGoogleAppSettings } from '@/server/googleAppSettings';
+import { loadLlmConfig, saveLlmConfig } from '@/server/llmConfig';
+import { loadPipelineConfig, savePipelineConfig } from '@/server/pipelineConfig';
+import type { GoogleServiceAccount, SecretsLoadResult, SecretsState } from '@/types/api';
+
+function isMaskedValue(raw: string): boolean {
+ const trimmed = raw.trim();
+ return (
+ trimmed === '' ||
+ trimmed === SECRETS_MASK_SENTINEL ||
+ trimmed.startsWith('••••') ||
+ trimmed === '{configured}'
+ );
+}
+
+function isServiceAccount(value: unknown): value is GoogleServiceAccount {
+ return (
+ value != null &&
+ typeof value === 'object' &&
+ (value as GoogleServiceAccount).type === 'service_account' &&
+ typeof (value as GoogleServiceAccount).client_email === 'string' &&
+ typeof (value as GoogleServiceAccount).private_key === 'string'
+ );
+}
+
+export async function loadSecrets(): Promise {
+ const [llm, pipeline, google] = await Promise.all([
+ loadLlmConfig(),
+ loadPipelineConfig(),
+ loadGoogleAppSettings(),
+ ]);
+
+ const state = buildInitialSecretsState();
+
+ state.llm_api_key = String(llm.state.llm_api_key || '');
+ if (llm.state.llm_api_key_masked) {
+ state.llm_api_key_masked = true;
+ }
+
+ for (const key of ALL_SECRETS_KEYS) {
+ const field = getSecretsFieldByKey(key);
+ if (!field || field.storage !== 'pipeline') continue;
+ const raw = pipeline.state[key];
+ if (raw == null || String(raw).trim() === '') continue;
+ if (field.type === 'secret') {
+ state[key] = maskSecretForClient(raw);
+ state[`${key}_masked`] = true;
+ } else {
+ state[key] = String(raw);
+ }
+ }
+
+ if (google.clientId) {
+ state.google_client_id = google.clientId;
+ }
+ if (google.clientSecret) {
+ state.google_client_secret = maskSecretForClient(google.clientSecret);
+ state.google_client_secret_masked = true;
+ }
+ if (google.serviceAccount) {
+ state.google_service_account_json = '{configured}';
+ state.google_service_account_json_masked = true;
+ state.google_has_service_account = true;
+ }
+
+ return { state, envHints: collectEnvHints() };
+}
+
+export async function saveSecrets(rawState: SecretsState): Promise {
+ const [llmLoaded, pipelineLoaded, googleLoaded] = await Promise.all([
+ loadLlmConfig(),
+ loadPipelineConfig(),
+ loadGoogleAppSettings(),
+ ]);
+
+ const llmState = { ...llmLoaded.state };
+ if (rawState.llm_api_key !== undefined) {
+ llmState.llm_api_key = String(rawState.llm_api_key ?? '');
+ if (rawState.llm_api_key_masked === true) {
+ llmState.llm_api_key_masked = true;
+ } else {
+ delete llmState.llm_api_key_masked;
+ }
+ }
+
+ const pipelineState = { ...pipelineLoaded.state };
+ for (const section of SECRETS_SECTIONS) {
+ for (const field of section.fields) {
+ if (field.storage !== 'pipeline') continue;
+ if (rawState[field.key] === undefined) continue;
+ pipelineState[field.key] = String(rawState[field.key] ?? '');
+ if (rawState[`${field.key}_masked`] === true) {
+ pipelineState[`${field.key}_masked`] = true;
+ } else {
+ delete pipelineState[`${field.key}_masked`];
+ }
+ }
+ }
+
+ const googlePatch: Parameters[0] = {};
+ if (rawState.google_client_id !== undefined) {
+ googlePatch.clientId = String(rawState.google_client_id ?? '').trim();
+ }
+ if (rawState.google_client_secret !== undefined) {
+ const raw = String(rawState.google_client_secret ?? '').trim();
+ if (!isMaskedValue(raw)) {
+ googlePatch.clientSecret = raw;
+ }
+ }
+ if (rawState.google_service_account_json !== undefined) {
+ const raw = String(rawState.google_service_account_json ?? '').trim();
+ if (raw && !isMaskedValue(raw)) {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ throw new Error("Service account JSON is not valid JSON.");
+ }
+ if (!isServiceAccount(parsed)) {
+ throw new Error(
+ "Service account JSON must be a Google service account key (type: service_account).",
+ );
+ }
+ googlePatch.serviceAccount = parsed;
+ }
+ }
+
+ await saveLlmConfig(llmState);
+ await savePipelineConfig(pipelineState, { unknownKeys: pipelineLoaded.unknownKeys });
+ if (Object.keys(googlePatch).length > 0) {
+ await saveGoogleAppSettings(googlePatch, { preserveSecret: true });
+ } else if (googleLoaded.clientSecret && rawState.google_client_secret_masked) {
+ // no-op: masked secret preserved by saveGoogleAppSettings when not in patch
+ }
+}
+
+export function maskSecretsStateForClient(state: SecretsState): SecretsState {
+ const out: SecretsState = { ...state };
+ for (const key of Object.keys(out)) {
+ if (key.endsWith('_masked') || key === 'google_has_service_account') continue;
+ if (!ALL_SECRETS_KEYS.has(key)) continue;
+ const field = getSecretsFieldByKey(key);
+ if (!field) continue;
+ if (field.type === 'secret') {
+ const masked = maskSecretForClient(out[key]);
+ if (masked) {
+ out[key] = masked;
+ out[`${key}_masked`] = true;
+ }
+ }
+ if (field.key === 'google_service_account_json' && out.google_has_service_account) {
+ out.google_service_account_json = '{configured}';
+ out.google_service_account_json_masked = true;
+ }
+ }
+ return out;
+}
diff --git a/web/src/server/secretsRoute.test.ts b/web/src/server/secretsRoute.test.ts
new file mode 100644
index 0000000..d7d1fc5
--- /dev/null
+++ b/web/src/server/secretsRoute.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+import { GET, PUT } from '../../app/api/secrets/route';
+import { forbiddenIfNotLocal } from '@/server/localOnly';
+import { localRequest, remoteRequest } from '@/server/testHelpers/routeTestUtils';
+
+describe('/api/secrets route guards', () => {
+ it('rejects non-local hosts', () => {
+ const denied = forbiddenIfNotLocal(remoteRequest('/api/secrets'));
+ expect(denied?.status).toBe(403);
+ });
+
+ it('GET returns 403 for remote host', async () => {
+ const res = await GET(remoteRequest('/api/secrets'));
+ expect(res.status).toBe(403);
+ });
+
+ it('PUT returns 400 for invalid JSON', async () => {
+ const res = await PUT(
+ localRequest('/api/secrets', {
+ method: 'PUT',
+ body: 'not-json',
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ );
+ expect(res.status).toBe(400);
+ });
+});
diff --git a/web/src/strings.json b/web/src/strings.json
index 3cbc4cb..d350518 100644
--- a/web/src/strings.json
+++ b/web/src/strings.json
@@ -712,6 +712,10 @@
"label": "Run audit",
"section": "Overview"
},
+ "secrets": {
+ "label": "Secrets",
+ "section": "Overview"
+ },
"chat": {
"label": "AI Chat",
"section": "AI"
@@ -827,6 +831,31 @@
"titleLoadReport": "Load a previous audit",
"titleCompareBaseline": "Older audit to compare against (new, removed, and changed URLs)"
},
+ "secrets": {
+ "pageTitle": "Secrets",
+ "pageSubtitle": "API keys and credentials stored in the database. Values are masked after save.",
+ "sidebarTitle": "Secrets",
+ "expandSidebar": "Show sidebar",
+ "collapseSidebar": "Hide sidebar",
+ "sectionsLabel": "Secret categories",
+ "pipelineSettingsLink": "Pipeline settings",
+ "backToPipeline": "Run audit",
+ "saveButton": "Save secrets",
+ "saving": "Saving…",
+ "saveHint": "Changes are stored in PostgreSQL only — not in audit settings files.",
+ "loading": "Loading secrets…",
+ "envConfigured": "Environment variables set",
+ "serviceAccountSaved": "Service account key saved. Paste new JSON to replace.",
+ "serviceAccountReplacePlaceholder": "Paste service account JSON to replace the saved key",
+ "googleConnectHint": "After saving Client ID and Secret, connect your Google account on",
+ "googleConnectLink": "Pipeline → Google",
+ "aiProviderHint": "Choose provider and model on",
+ "aiProviderLink": "Pipeline → Content & AI",
+ "pipelineBanner": "API keys and credentials are managed on the",
+ "googleConfigured": "Google Cloud credentials are configured.",
+ "googleNotConfigured": "Google Cloud credentials are not configured yet.",
+ "googleCredentialsHint": "Add OAuth Client ID, Client Secret, or service account JSON on the"
+ },
"pipelineRunner": {
"fabTitle": "Run audit",
"fabAriaOpen": "Open Run audit",
@@ -868,6 +897,8 @@
"wizardUrlHint": "Enter the site you want to analyze.",
"wizardWorkflowHint": "Choose what to include: full site audit, crawl only, Lighthouse, Google data, or keywords.",
"pageTitle": "Run audit",
+ "expandSidebar": "Show sidebar",
+ "collapseSidebar": "Hide sidebar",
"pageTabsLabel": "Run audit sections",
"tabRun": "Run",
"tabSettings": "Settings",
@@ -1434,13 +1465,16 @@
"navGithub": "GitHub",
"navOpenApp": "Dashboard",
"navRunAudit": "Run audit",
- "heroBadge": "Open source · Self-hosted",
- "heroTitleLine1": "Self-hosted technical SEO",
- "heroTitleAccent": "audits you control",
+ "heroEyebrow": "Open source · Self-hosted",
+ "heroTitle": "Technical SEO audits you control",
"heroSubtitle": "Crawl every URL, prioritize real issues, connect Search Console and Analytics, and export client reports — on infrastructure you own.",
+ "heroBullets": [
+ "No subscription tiers — MIT licensed and self-hosted",
+ "Your crawl data and reports stay on your infrastructure",
+ "Export HTML and PDF reports ready for clients"
+ ],
"heroProofNoSubscription": "No subscription tiers",
"heroProofLocalData": "Your data stays local",
- "heroProofExport": "Export HTML & PDF",
"ctaDashboard": "Open dashboard",
"ctaRunAudit": "Run your first audit",
"ctaGoogleGuide": "Google setup guide",
diff --git a/web/src/types/api.ts b/web/src/types/api.ts
index fe4e762..cc320e6 100644
--- a/web/src/types/api.ts
+++ b/web/src/types/api.ts
@@ -44,6 +44,16 @@ export interface PipelineUnknownKey {
export type PipelineConfigState = Record;
export type LlmConfigState = Record;
+export type SecretsState = Record;
+
+export interface SecretsLoadResult {
+ state: SecretsState;
+ envHints: Record;
+}
+
+export interface SecretsPutBody {
+ state?: SecretsState;
+}
export type PipelineConfigSource = 'store' | 'legacy' | 'defaults';
diff --git a/web/src/views/Chat.tsx b/web/src/views/Chat.tsx
index a89c791..efeb281 100644
--- a/web/src/views/Chat.tsx
+++ b/web/src/views/Chat.tsx
@@ -49,7 +49,7 @@ export default function ChatPage() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
- const { configState, configLoaded } = usePipeline();
+ const { configState, configLoaded, llmConfigState } = usePipeline();
const initialUrlCtx = parseChatUrlContext(searchParams);
const [properties, setProperties] = useState([]);
const [propertyId, setPropertyId] = useState(initialUrlCtx.propertyId);
@@ -524,7 +524,7 @@ export default function ChatPage() {
{c.aiDisabledTitle}
{c.aiDisabledHint}
-
+
{c.openAiSettings}
diff --git a/web/src/views/Landing.tsx b/web/src/views/Landing.tsx
index 8002d3d..287162c 100644
--- a/web/src/views/Landing.tsx
+++ b/web/src/views/Landing.tsx
@@ -1,9 +1,6 @@
'use client';
-import Link from 'next/link';
import {
- ArrowDown,
- Check,
Cpu,
Gauge,
Key,
@@ -11,9 +8,8 @@ import {
MessageSquare,
TrendingUp,
} from 'lucide-react';
-import AppLogo from '@/components/AppLogo';
-import Button from '@/components/Button';
import Reveal from '@/components/Reveal';
+import LandingHero from '@/components/landing/LandingHero';
import LandingCodeBlock from '@/components/landing/LandingCodeBlock';
import LandingFeatureSpotlight from '@/components/landing/LandingFeatureSpotlight';
import LandingFinalCta from '@/components/landing/LandingFinalCta';
@@ -21,7 +17,6 @@ import LandingFooter from '@/components/landing/LandingFooter';
import LandingGoogleSetup from '@/components/landing/LandingGoogleSetup';
import LandingLimitations from '@/components/landing/LandingLimitations';
import LandingPathStrip from '@/components/landing/LandingPathStrip';
-import LandingProductMock from '@/components/landing/LandingProductMock';
import LandingSectionHeader from '@/components/landing/LandingSectionHeader';
import LandingShell from '@/components/LandingShell';
import LandingStatsStrip from '@/components/landing/LandingStatsStrip';
@@ -30,8 +25,6 @@ import { strings } from '@/lib/strings';
const vl = strings.views.landing;
-const HERO_PROOF = [vl.heroProofNoSubscription, vl.heroProofLocalData, vl.heroProofExport] as const;
-
const FEATURES = [
{ icon: Gauge, title: vl.featureOnPageTitle, description: vl.featureOnPageDescription },
{ icon: TrendingUp, title: vl.featureSearchTitle, description: vl.featureSearchDescription },
@@ -48,57 +41,7 @@ export default function LandingPage() {
-
-
-
-
- {vl.heroBadge}
-
-
-
-
-
{strings.app.productName}
-
-
- {vl.heroTitleLine1}
- {vl.heroTitleAccent}
-
-
- {vl.heroSubtitle}
-
-
-
-
-
-
-
-
-
-
- {HERO_PROOF.map((item) => (
- -
-
- {item}
-
- ))}
-
-
-
- {vl.scrollHint}
-
-
-
-
-
-
-
+
diff --git a/web/src/views/Secrets.tsx b/web/src/views/Secrets.tsx
new file mode 100644
index 0000000..f17ee71
--- /dev/null
+++ b/web/src/views/Secrets.tsx
@@ -0,0 +1,67 @@
+'use client';
+
+import { useState } from 'react';
+import { Loader2 } from 'lucide-react';
+import ChatShell from '@/components/chat/ChatShell';
+import SecretsContextBar from '@/components/secrets/SecretsContextBar';
+import SecretsSidebar from '@/components/secrets/SecretsSidebar';
+import SecretsSettingsPanel, { SecretsSaveBar } from '@/components/secrets/SecretsSettingsPanel';
+import { useSecrets } from '@/hooks/useSecrets';
+import { useReadOnlySession } from '@/hooks/useReadOnlySession';
+import { SECRETS_SECTIONS, type SecretsNavId } from '@/lib/secretsConfigSchema';
+import { strings } from '@/lib/strings';
+
+const s = strings.secrets;
+
+export default function SecretsPage() {
+ const [activeSection, setActiveSection] = useState(SECRETS_SECTIONS[0].id);
+ const { state, envHints, loading, saving, saveMsg, loadError, setField, save } = useSecrets();
+ const { readOnly } = useReadOnlySession();
+
+ return (
+ (
+
+ )}
+ >
+
+
+
+
+ {loading ? (
+
+
+ {s.loading}
+
+ ) : loadError ? (
+
+ {loadError}
+
+ ) : (
+
+ )}
+
+
+
+
+
+ );
+}
From 4649d2877a05b38dce84b15a71d013445653c121 Mon Sep 17 00:00:00 2001
From: PrashantUnity
Date: Wed, 17 Jun 2026 01:30:41 +0530
Subject: [PATCH 04/11] missed you
---
web/src/components/pipeline/PipelineShell.tsx | 257 ---------------
.../components/pipeline/PipelineSidebar.tsx | 307 ++++++++++++++++++
web/src/components/shell/shellNavStyles.ts | 2 +-
web/src/views/Pipeline.tsx | 56 ++--
4 files changed, 343 insertions(+), 279 deletions(-)
delete mode 100644 web/src/components/pipeline/PipelineShell.tsx
create mode 100644 web/src/components/pipeline/PipelineSidebar.tsx
diff --git a/web/src/components/pipeline/PipelineShell.tsx b/web/src/components/pipeline/PipelineShell.tsx
deleted file mode 100644
index f778c04..0000000
--- a/web/src/components/pipeline/PipelineShell.tsx
+++ /dev/null
@@ -1,257 +0,0 @@
-'use client';
-
-import { useState, type ReactNode } from 'react';
-import Link from 'next/link';
-import { ArrowLeft, Menu, Play, X } from 'lucide-react';
-import AppLogo from '@/components/AppLogo';
-import ThemeToggle from '@/components/ThemeToggle';
-import Breadcrumb from '@/components/Breadcrumb';
-import { strings } from '@/lib/strings';
-import { readPipelineReturnPath } from '@/lib/pipelineReturn';
-import {
- PIPELINE_SETTINGS_GROUPS,
- type PipelineSettingsGroupId,
-} from '@/components/pipeline/pipelineSettingsGroups';
-import { SETTINGS_GROUP_ICONS } from '@/components/pipeline/pipelineUi';
-
-const s = strings.pipelineRunner;
-const groupLabels = s.settingsGroups;
-const groupDescriptions = s.settingsGroupDescriptions;
-
-function settingsGroupLabel(labelKey: string): string {
- return (groupLabels as Record)[labelKey] ?? labelKey;
-}
-
-function settingsGroupDescription(labelKey: string): string {
- return (groupDescriptions as Record)[labelKey] ?? '';
-}
-
-export type PipelineNavId = 'run' | PipelineSettingsGroupId;
-
-export interface PipelineShellProps {
- children: ReactNode;
- activeNav: PipelineNavId;
- onNavChange: (nav: PipelineNavId) => void;
- headerExtra?: ReactNode;
- footer?: ReactNode;
-}
-
-export default function PipelineShell({
- children,
- activeNav,
- onNavChange,
- headerExtra,
- footer,
-}: PipelineShellProps) {
- const [sidebarOpen, setSidebarOpen] = useState(false);
- const backHref = readPipelineReturnPath();
- const currentLabel =
- activeNav === 'run'
- ? s.runTitle
- : settingsGroupLabel(PIPELINE_SETTINGS_GROUPS.find((g) => g.id === activeNav)?.labelKey ?? '');
- const subtitle = activeNav === 'run' ? s.runSubtitle : s.settingsSubtitle;
-
- const closeSidebar = () => setSidebarOpen(false);
-
- const navItemClass = (selected: boolean) =>
- `nav-btn press relative w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all ${
- selected
- ? 'tab-active bg-blue-500/10 border border-blue-500/25 text-link'
- : 'text-muted-foreground hover:text-foreground hover:bg-brand-700/80'
- }`;
-
- const activeRail = (
-
- );
-
- const selectNav = (nav: PipelineNavId) => {
- onNavChange(nav);
- closeSidebar();
- };
-
- return (
-
- {sidebarOpen ? (
-
- ) : null}
-
-
-
-
-
-
-
-
-
-
-
-
{currentLabel}
-
-
{subtitle}
-
-
-
- {headerExtra}
-
-
-
-
-
-
- {footer ? (
-
- {footer}
-
- ) : null}
-
-
-
- );
-}
-
-export function pipelineNavFromSearchParams(
- searchParams: URLSearchParams,
-): PipelineNavId {
- const group = searchParams.get('group');
- if (
- group === 'crawl-report' ||
- group === 'lighthouse' ||
- group === 'keywords' ||
- group === 'google' ||
- group === 'content-ai' ||
- group === 'advanced'
- ) {
- return group;
- }
- if (searchParams.get('tab') === 'settings') {
- return 'crawl-report';
- }
- return 'run';
-}
-
-export function pipelineHrefForNav(
- nav: PipelineNavId,
- existingParams?: URLSearchParams,
-): string {
- const params = new URLSearchParams(existingParams?.toString() ?? '');
- params.delete('tab');
- if (nav === 'run') {
- params.delete('group');
- } else {
- params.set('group', nav);
- }
- const preset = params.get('preset');
- if (preset) params.set('preset', preset);
- const q = params.toString();
- return q ? `/pipeline?${q}` : '/pipeline';
-}
diff --git a/web/src/components/pipeline/PipelineSidebar.tsx b/web/src/components/pipeline/PipelineSidebar.tsx
new file mode 100644
index 0000000..f8c94ea
--- /dev/null
+++ b/web/src/components/pipeline/PipelineSidebar.tsx
@@ -0,0 +1,307 @@
+'use client';
+
+import { useEffect, useRef, useState, type ReactNode } from 'react';
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+import {
+ ChevronLeft,
+ PanelLeft,
+ Play,
+ Settings,
+ SlidersHorizontal,
+ type LucideIcon,
+} from 'lucide-react';
+import AppLogo from '@/components/AppLogo';
+import ThemeToggle from '@/components/ThemeToggle';
+import type { ChatLayoutState } from '@/components/chat/ChatShell';
+import {
+ PIPELINE_SETTINGS_GROUPS,
+ type PipelineSettingsGroupId,
+} from '@/components/pipeline/pipelineSettingsGroups';
+import { SETTINGS_GROUP_ICONS } from '@/components/pipeline/pipelineUi';
+import {
+ PIPELINE_SIDEBAR_NAV_IDS,
+ isMiniNavLinkActive,
+ miniNavLinks,
+} from '@/lib/appNav';
+import type { PipelineNavId } from '@/lib/pipelineNav';
+import { strings } from '@/lib/strings';
+
+const s = strings.pipelineRunner;
+const c = strings.components.chat;
+const groupLabels = s.settingsGroups;
+const groupDescriptions = s.settingsGroupDescriptions;
+
+const NAV_LINKS = miniNavLinks(PIPELINE_SIDEBAR_NAV_IDS);
+
+function settingsGroupLabel(labelKey: string): string {
+ return (groupLabels as Record)[labelKey] ?? labelKey;
+}
+
+function settingsGroupDescription(labelKey: string): string {
+ return (groupDescriptions as Record)[labelKey] ?? '';
+}
+
+export interface PipelineSidebarProps extends ChatLayoutState {
+ activeNav: PipelineNavId;
+ onNavChange: (nav: PipelineNavId) => void;
+}
+
+function RailButton({
+ label,
+ onClick,
+ children,
+ active,
+}: {
+ label: string;
+ onClick?: () => void;
+ children: ReactNode;
+ active?: boolean;
+}) {
+ return (
+
+ );
+}
+
+function SettingsMenu({ onClose }: { onClose: () => void }) {
+ return (
+
+
{c.settingsTitle}
+
+ Theme
+
+
+
+ {strings.secrets.pageTitle}
+
+
+ );
+}
+
+function navIcon(nav: PipelineNavId): LucideIcon {
+ if (nav === 'run') return Play;
+ return SETTINGS_GROUP_ICONS[nav as PipelineSettingsGroupId];
+}
+
+export default function PipelineSidebar({
+ activeNav,
+ onNavChange,
+ expanded,
+ toggle,
+ setExpanded,
+}: PipelineSidebarProps) {
+ const pathname = usePathname();
+ const [settingsOpen, setSettingsOpen] = useState(false);
+ const settingsRef = useRef(null);
+
+ useEffect(() => {
+ if (!settingsOpen) return;
+ const onDocClick = (e: MouseEvent) => {
+ if (settingsRef.current && !settingsRef.current.contains(e.target as Node)) {
+ setSettingsOpen(false);
+ }
+ };
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') setSettingsOpen(false);
+ };
+ document.addEventListener('mousedown', onDocClick);
+ document.addEventListener('keydown', onKey);
+ return () => {
+ document.removeEventListener('mousedown', onDocClick);
+ document.removeEventListener('keydown', onKey);
+ };
+ }, [settingsOpen]);
+
+ const runButton = (compact: boolean) => (
+
+ );
+
+ const settingsList = (
+
+ {PIPELINE_SETTINGS_GROUPS.map((group) => {
+ const Icon = SETTINGS_GROUP_ICONS[group.id];
+ const selected = activeNav === group.id;
+ const description = settingsGroupDescription(group.labelKey);
+ return (
+ -
+
+
+ );
+ })}
+
+ );
+
+ if (!expanded) {
+ const ActiveIcon = navIcon(activeNav);
+ return (
+
+
+
+
+
+
setExpanded(true)}>
+
+
+
+
setExpanded(true)}
+ active
+ >
+
+
+
+
setExpanded(true)}>
+
+
+
+
+
setSettingsOpen((v) => !v)}
+ active={settingsOpen}
+ >
+
+
+ {settingsOpen ? (
+
+ setSettingsOpen(false)} />
+
+ ) : null}
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/web/src/components/shell/shellNavStyles.ts b/web/src/components/shell/shellNavStyles.ts
index e283452..9dd785f 100644
--- a/web/src/components/shell/shellNavStyles.ts
+++ b/web/src/components/shell/shellNavStyles.ts
@@ -1,4 +1,4 @@
-/** Shared nav/shell class strings — matches AppShell & PipelineShell. */
+/** Shared nav/shell class strings — matches AppShell & chat-style sidebars. */
export const shellSidebarAsideClass =
'flex shrink-0 flex-col border-r border-muted bg-brand-800';
diff --git a/web/src/views/Pipeline.tsx b/web/src/views/Pipeline.tsx
index a86331b..d3ec950 100644
--- a/web/src/views/Pipeline.tsx
+++ b/web/src/views/Pipeline.tsx
@@ -3,19 +3,22 @@
import { useEffect, useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import type { IntegrationToast } from '@/types/api';
+import ChatShell from '@/components/chat/ChatShell';
import PageLayout from '@/components/PageLayout';
+import PipelineContextBar from '@/components/pipeline/PipelineContextBar';
import PipelineRunPanel from '@/components/pipeline/PipelineRunPanel';
import PipelineSettingsPanel, { PipelineSettingsSaveBar } from '@/components/pipeline/PipelineSettingsPanel';
-import PipelineShell, {
- pipelineHrefForNav,
- pipelineNavFromSearchParams,
- type PipelineNavId,
-} from '@/components/pipeline/PipelineShell';
+import PipelineSidebar from '@/components/pipeline/PipelineSidebar';
import { PipelineStatusBadge, PipelineStopButton } from '@/components/pipeline/pipelineUi';
import { isPipelinePresetId } from '@/components/pipeline/pipelinePresets';
import { usePipeline } from '@/context/PipelineContext';
import { useReadOnlySession } from '@/hooks/useReadOnlySession';
import { OPEN_INTEGRATIONS } from '@/lib/pipelineJobEvents';
+import {
+ pipelineHrefForNav,
+ pipelineNavFromSearchParams,
+ type PipelineNavId,
+} from '@/lib/pipelineNav';
export default function PipelinePage() {
const router = useRouter();
@@ -97,22 +100,33 @@ export default function PipelinePage() {
) : null;
return (
- : undefined}
+ (
+
+ )}
>
-
- {activeNav === 'run' ? (
-
- ) : (
-
- )}
-
-
+
+
+
+
+
+ {activeNav === 'run' ? (
+
+ ) : (
+
+ )}
+
+
+
+ {activeNav !== 'run' ? (
+
+ ) : null}
+
+
);
}
From abbbc1303932beede79dfda66fe3cc1abfa08b58 Mon Sep 17 00:00:00 2001
From: PrashantUnity
Date: Wed, 17 Jun 2026 01:36:17 +0530
Subject: [PATCH 05/11] missed someting
---
src/website_profiling/llm_config.py | 36 ++++++++---
tests/test_llm_config.py | 18 ++++++
web/src/lib/llmConfigSchema.ts | 2 +-
web/src/lib/llmProviderApiKeys.test.ts | 42 +++++++++++++
web/src/lib/llmProviderApiKeys.ts | 57 +++++++++++++++++
web/src/lib/secretsConfigSchema.ts | 24 +++++---
web/src/server/llmConfig.ts | 84 +++++++++++++++++++++-----
web/src/server/secrets.test.ts | 9 ++-
web/src/server/secrets.ts | 54 +++++++++++------
web/src/strings.json | 2 +-
10 files changed, 272 insertions(+), 56 deletions(-)
create mode 100644 web/src/lib/llmProviderApiKeys.test.ts
create mode 100644 web/src/lib/llmProviderApiKeys.ts
diff --git a/src/website_profiling/llm_config.py b/src/website_profiling/llm_config.py
index 8f1f892..97e475c 100644
--- a/src/website_profiling/llm_config.py
+++ b/src/website_profiling/llm_config.py
@@ -7,6 +7,8 @@
import os
from typing import Optional
+_LLM_CLOUD_PROVIDERS = ("openai", "gemini", "anthropic", "groq")
+
_ENV_KEY_BY_PROVIDER = {
"openai": "OPENAI_API_KEY",
"gemini": "GEMINI_API_KEY",
@@ -14,6 +16,19 @@
"groq": "GROQ_API_KEY",
}
+_PROVIDER_API_KEY_FIELDS = {
+ provider: f"llm_api_key_{provider}" for provider in _LLM_CLOUD_PROVIDERS
+}
+
+
+def _resolve_llm_api_key(cfg: dict[str, str]) -> str:
+ provider = (cfg.get("llm_provider") or "none").strip().lower()
+ if provider in _PROVIDER_API_KEY_FIELDS:
+ per_provider = (cfg.get(_PROVIDER_API_KEY_FIELDS[provider]) or "").strip()
+ if per_provider:
+ return per_provider
+ return (cfg.get("llm_api_key") or "").strip()
+
def load_llm_config_from_db() -> dict[str, str]:
try:
@@ -29,15 +44,18 @@ def load_llm_config_from_db() -> dict[str, str]:
return {}
provider = (cfg.get("llm_provider") or "none").strip().lower()
- if provider and provider != "none":
- if not (cfg.get("llm_api_key") or "").strip():
- env_var = _ENV_KEY_BY_PROVIDER.get(provider)
- if env_var:
- env_val = (os.environ.get(env_var) or "").strip()
- if env_val:
- cfg = dict(cfg)
- cfg["llm_api_key"] = env_val
- cfg["_llm_api_key_source"] = "env"
+ resolved = _resolve_llm_api_key(cfg)
+ if resolved:
+ cfg = dict(cfg)
+ cfg["llm_api_key"] = resolved
+ elif provider and provider != "none":
+ env_var = _ENV_KEY_BY_PROVIDER.get(provider)
+ if env_var:
+ env_val = (os.environ.get(env_var) or "").strip()
+ if env_val:
+ cfg = dict(cfg)
+ cfg["llm_api_key"] = env_val
+ cfg["_llm_api_key_source"] = "env"
return cfg
diff --git a/tests/test_llm_config.py b/tests/test_llm_config.py
index 78ec833..5299323 100644
--- a/tests/test_llm_config.py
+++ b/tests/test_llm_config.py
@@ -35,3 +35,21 @@ def test_load_llm_config_from_db(require_database_url):
def test_llm_disabled_by_default():
assert not llm_is_enabled({})
assert not llm_is_enabled({"llm_enabled": "false", "llm_provider": "openai"})
+
+
+def test_resolve_llm_api_key_per_provider():
+ from website_profiling.llm_config import _resolve_llm_api_key
+
+ cfg = {
+ "llm_provider": "groq",
+ "llm_api_key_groq": "gsk-test",
+ "llm_api_key_openai": "sk-openai",
+ "llm_api_key": "legacy",
+ }
+ assert _resolve_llm_api_key(cfg) == "gsk-test"
+
+ cfg["llm_provider"] = "openai"
+ assert _resolve_llm_api_key(cfg) == "sk-openai"
+
+ del cfg["llm_api_key_openai"]
+ assert _resolve_llm_api_key(cfg) == "legacy"
diff --git a/web/src/lib/llmConfigSchema.ts b/web/src/lib/llmConfigSchema.ts
index 23cb9f5..b7af5b4 100644
--- a/web/src/lib/llmConfigSchema.ts
+++ b/web/src/lib/llmConfigSchema.ts
@@ -50,7 +50,7 @@ export const LLM_CONFIG_SECTIONS = [
label: 'API key',
type: 'secret',
defaultValue: '',
- help: 'Optional when OPENAI_API_KEY, GEMINI_API_KEY, ANTHROPIC_API_KEY, or GROQ_API_KEY is set in the environment. Stored only in the database, not in saved audit settings files.',
+ help: 'Managed on the Secrets page (one key per provider). Or set provider keys in the environment.',
},
],
},
diff --git a/web/src/lib/llmProviderApiKeys.test.ts b/web/src/lib/llmProviderApiKeys.test.ts
new file mode 100644
index 0000000..f99f567
--- /dev/null
+++ b/web/src/lib/llmProviderApiKeys.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest';
+import {
+ llmProviderApiKeyField,
+ resolveLlmApiKey,
+} from '@/lib/llmProviderApiKeys';
+
+describe('resolveLlmApiKey', () => {
+ it('uses per-provider key for the active provider', () => {
+ expect(
+ resolveLlmApiKey({
+ llm_provider: 'groq',
+ llm_api_key_groq: 'gsk-provider-key',
+ llm_api_key_openai: 'sk-openai',
+ }),
+ ).toBe('gsk-provider-key');
+ });
+
+ it('falls back to legacy llm_api_key when provider key is missing', () => {
+ expect(
+ resolveLlmApiKey({
+ llm_provider: 'openai',
+ llm_api_key: 'sk-legacy',
+ }),
+ ).toBe('sk-legacy');
+ });
+
+ it('ignores masked values', () => {
+ expect(
+ resolveLlmApiKey({
+ llm_provider: 'openai',
+ llm_api_key_openai: '••••cdef',
+ }),
+ ).toBe('');
+ });
+});
+
+describe('llmProviderApiKeyField', () => {
+ it('names keys consistently', () => {
+ expect(llmProviderApiKeyField('openai')).toBe('llm_api_key_openai');
+ expect(llmProviderApiKeyField('groq')).toBe('llm_api_key_groq');
+ });
+});
diff --git a/web/src/lib/llmProviderApiKeys.ts b/web/src/lib/llmProviderApiKeys.ts
new file mode 100644
index 0000000..0444b0f
--- /dev/null
+++ b/web/src/lib/llmProviderApiKeys.ts
@@ -0,0 +1,57 @@
+/**
+ * Per-provider LLM API keys stored in llm_config (llm_api_key_).
+ * The active provider from Pipeline → Content & AI resolves to llm_api_key at runtime.
+ */
+export const LLM_CLOUD_PROVIDERS = ['openai', 'gemini', 'anthropic', 'groq'] as const;
+
+export type LlmCloudProvider = (typeof LLM_CLOUD_PROVIDERS)[number];
+
+export const LLM_PROVIDER_LABELS: Record = {
+ openai: 'OpenAI',
+ gemini: 'Google Gemini',
+ anthropic: 'Anthropic Claude',
+ groq: 'Groq',
+};
+
+export const LLM_PROVIDER_ENV_VARS: Record = {
+ openai: 'OPENAI_API_KEY',
+ gemini: 'GEMINI_API_KEY',
+ anthropic: 'ANTHROPIC_API_KEY',
+ groq: 'GROQ_API_KEY',
+};
+
+export function llmProviderApiKeyField(provider: LlmCloudProvider): string {
+ return `llm_api_key_${provider}`;
+}
+
+export const ALL_LLM_PROVIDER_API_KEY_KEYS = new Set(
+ LLM_CLOUD_PROVIDERS.map((provider) => llmProviderApiKeyField(provider)),
+);
+
+export function isLlmProviderApiKeyField(key: string): boolean {
+ return ALL_LLM_PROVIDER_API_KEY_KEYS.has(key);
+}
+
+export function isLlmCloudProvider(provider: string): provider is LlmCloudProvider {
+ return (LLM_CLOUD_PROVIDERS as readonly string[]).includes(provider);
+}
+
+/** Resolve the API key for the selected (or given) cloud provider. */
+export function resolveLlmApiKey(
+ cfg: Record,
+ provider?: string,
+): string {
+ const selected = (provider ?? String(cfg.llm_provider ?? 'none')).trim().toLowerCase();
+ if (isLlmCloudProvider(selected)) {
+ const field = llmProviderApiKeyField(selected);
+ const perProvider = String(cfg[field] ?? '').trim();
+ if (perProvider && !perProvider.startsWith('••••')) {
+ return perProvider;
+ }
+ }
+ const legacy = String(cfg.llm_api_key ?? '').trim();
+ if (legacy && !legacy.startsWith('••••')) {
+ return legacy;
+ }
+ return '';
+}
diff --git a/web/src/lib/secretsConfigSchema.ts b/web/src/lib/secretsConfigSchema.ts
index 5c7d19b..929db45 100644
--- a/web/src/lib/secretsConfigSchema.ts
+++ b/web/src/lib/secretsConfigSchema.ts
@@ -3,6 +3,12 @@
* Values are stored in llm_config, pipeline_config, or google_app_settings.
*/
import type { SecretsState } from '@/types/api';
+import {
+ LLM_CLOUD_PROVIDERS,
+ LLM_PROVIDER_ENV_VARS,
+ LLM_PROVIDER_LABELS,
+ llmProviderApiKeyField,
+} from '@/lib/llmProviderApiKeys';
export type SecretsStorage = 'llm' | 'pipeline' | 'google';
@@ -38,16 +44,14 @@ export const SECRETS_SECTIONS: SecretsSection[] = [
{
id: 'ai',
label: 'AI providers',
- fields: [
- {
- key: 'llm_api_key',
- label: 'LLM API key',
- type: 'secret',
- storage: 'llm',
- help: 'For the provider selected in Pipeline → Content & AI. Or set provider keys in the environment (see hints below).',
- envVars: ['OPENAI_API_KEY', 'GEMINI_API_KEY', 'ANTHROPIC_API_KEY', 'GROQ_API_KEY'],
- },
- ],
+ fields: LLM_CLOUD_PROVIDERS.map((provider) => ({
+ key: llmProviderApiKeyField(provider),
+ label: `${LLM_PROVIDER_LABELS[provider]} API key`,
+ type: 'secret' as const,
+ storage: 'llm' as const,
+ help: 'Saved per provider. Pipeline → Content & AI uses the key for the active provider automatically.',
+ envVars: [LLM_PROVIDER_ENV_VARS[provider]],
+ })),
},
{
id: 'google',
diff --git a/web/src/server/llmConfig.ts b/web/src/server/llmConfig.ts
index 1046bb5..329d0b5 100644
--- a/web/src/server/llmConfig.ts
+++ b/web/src/server/llmConfig.ts
@@ -10,6 +10,11 @@ import {
maskLlmSecretForClient,
isLlmSecretKey,
} from '@/lib/llmConfigSchema';
+import {
+ ALL_LLM_PROVIDER_API_KEY_KEYS,
+ isLlmProviderApiKeyField,
+ resolveLlmApiKey,
+} from '@/lib/llmProviderApiKeys';
import { withDb } from '@/server/db';
import type { LlmConfigLoadResult, LlmConfigState } from '@/types/api';
@@ -45,6 +50,38 @@ function applyLlmDefaults(parsedMap: Record): LlmConfigState {
return state;
}
+function applyProviderApiKeys(parsedMap: Record, state: LlmConfigState): void {
+ for (const key of ALL_LLM_PROVIDER_API_KEY_KEYS) {
+ if (parsedMap[key] != null && String(parsedMap[key]).trim() !== '') {
+ state[key] = String(parsedMap[key]);
+ }
+ }
+}
+
+function writeSecretEntry(
+ key: string,
+ value: string | boolean | undefined,
+ maskedFlag: string | boolean | undefined,
+ existing: Record,
+ entries: Record,
+ secretKeys: Set,
+): void {
+ const raw = value == null ? '' : String(value).trim();
+ const isMasked =
+ raw === '' ||
+ raw === MASK_SENTINEL ||
+ raw.startsWith('••••') ||
+ maskedFlag === true;
+ if (isMasked && existing[key]) {
+ entries[key] = existing[key];
+ } else if (raw && !raw.startsWith('••••')) {
+ entries[key] = raw;
+ } else {
+ entries[key] = '';
+ }
+ if (entries[key]) secretKeys.add(key);
+}
+
export function maskLlmStateForClient(state: LlmConfigState): LlmConfigState {
const out: LlmConfigState = { ...state };
for (const key of Object.keys(out)) {
@@ -56,11 +93,20 @@ export function maskLlmStateForClient(state: LlmConfigState): LlmConfigState {
return out;
}
+export async function readLlmConfigRaw(): Promise> {
+ return withDb(readLlmConfigFromDb);
+}
+
export async function loadLlmConfig(): Promise {
return withDb(async (client: PoolClient) => {
const known = await readLlmConfigFromDb(client);
if (Object.keys(known).length > 0) {
const state = applyLlmDefaults(known);
+ applyProviderApiKeys(known, state);
+ const resolved = resolveLlmApiKey({ ...known, ...state });
+ if (resolved) {
+ state.llm_api_key = resolved;
+ }
return { state: maskLlmStateForClient(state), source: 'store' };
}
return { state: maskLlmStateForClient(buildInitialLlmConfigState()), source: 'defaults' };
@@ -86,27 +132,35 @@ export async function saveLlmConfig(
if (v === undefined) continue;
if (f.type === 'bool') {
entries[f.key] = v === true ? 'true' : 'false';
- } else if (isLlmSecretKey(f.key)) {
- const raw = v == null ? '' : String(v).trim();
- const isMasked =
- raw === '' ||
- raw === MASK_SENTINEL ||
- raw.startsWith('••••') ||
- state[`${f.key}_masked`] === true;
- if (isMasked && existing[f.key]) {
- entries[f.key] = existing[f.key];
- } else if (raw && !raw.startsWith('••••')) {
- entries[f.key] = raw;
- } else {
- entries[f.key] = '';
- }
- if (entries[f.key]) secretKeys.add(f.key);
+ } else if (isLlmSecretKey(f.key) || isLlmProviderApiKeyField(f.key)) {
+ writeSecretEntry(f.key, v, state[`${f.key}_masked`], existing, entries, secretKeys);
} else {
entries[f.key] = v == null ? '' : String(v);
}
}
}
+ for (const key of ALL_LLM_PROVIDER_API_KEY_KEYS) {
+ if (state[key] !== undefined) {
+ writeSecretEntry(key, state[key], state[`${key}_masked`], existing, entries, secretKeys);
+ } else if (existing[key] && entries[key] === undefined) {
+ entries[key] = existing[key];
+ secretKeys.add(key);
+ }
+ }
+
+ for (const section of LLM_CONFIG_SECTIONS) {
+ for (const f of section.fields) {
+ if (entries[f.key] !== undefined) continue;
+ if (existing[f.key] !== undefined) {
+ entries[f.key] = existing[f.key];
+ if (isLlmSecretKey(f.key) && existing[f.key]) {
+ secretKeys.add(f.key);
+ }
+ }
+ }
+ }
+
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
await client.query('BEGIN');
diff --git a/web/src/server/secrets.test.ts b/web/src/server/secrets.test.ts
index ce8d9a4..09d0565 100644
--- a/web/src/server/secrets.test.ts
+++ b/web/src/server/secrets.test.ts
@@ -54,9 +54,11 @@ describe('loadSecrets', () => {
it('aggregates masked secrets from stores', async () => {
vi.doMock('@/server/llmConfig', () => ({
- loadLlmConfig: vi.fn().mockResolvedValue({
- state: { llm_api_key: '••••cdef', llm_api_key_masked: true },
+ readLlmConfigRaw: vi.fn().mockResolvedValue({
+ llm_provider: 'openai',
+ llm_api_key_openai: 'sk-openai-secret',
}),
+ saveLlmConfig: vi.fn(),
}));
vi.doMock('@/server/pipelineConfig', () => ({
loadPipelineConfig: vi.fn().mockResolvedValue({
@@ -78,7 +80,8 @@ describe('loadSecrets', () => {
const { loadSecrets } = await import('@/server/secrets');
const result = await loadSecrets();
- expect(result.state.llm_api_key).toBe('••••cdef');
+ expect(result.state.llm_api_key_openai).toBe('••••cret');
+ expect(result.state.llm_api_key_openai_masked).toBe(true);
expect(result.state.google_client_id).toBe('client.apps.googleusercontent.com');
expect(result.state.google_client_secret).toBe('••••cret');
expect(result.envHints).toBeTypeOf('object');
diff --git a/web/src/server/secrets.ts b/web/src/server/secrets.ts
index 326cf58..27db0a1 100644
--- a/web/src/server/secrets.ts
+++ b/web/src/server/secrets.ts
@@ -11,8 +11,13 @@ import {
isSecretsSecretKey,
maskSecretForClient,
} from '@/lib/secretsConfigSchema';
+import {
+ ALL_LLM_PROVIDER_API_KEY_KEYS,
+ isLlmCloudProvider,
+ llmProviderApiKeyField,
+} from '@/lib/llmProviderApiKeys';
import { loadGoogleAppSettings, saveGoogleAppSettings } from '@/server/googleAppSettings';
-import { loadLlmConfig, saveLlmConfig } from '@/server/llmConfig';
+import { readLlmConfigRaw, saveLlmConfig } from '@/server/llmConfig';
import { loadPipelineConfig, savePipelineConfig } from '@/server/pipelineConfig';
import type { GoogleServiceAccount, SecretsLoadResult, SecretsState } from '@/types/api';
@@ -36,19 +41,36 @@ function isServiceAccount(value: unknown): value is GoogleServiceAccount {
);
}
+function loadLlmProviderSecrets(rawLlm: Record, state: SecretsState): void {
+ for (const key of ALL_LLM_PROVIDER_API_KEY_KEYS) {
+ const raw = String(rawLlm[key] || '').trim();
+ if (!raw) continue;
+ state[key] = maskSecretForClient(raw);
+ state[`${key}_masked`] = true;
+ }
+
+ const legacy = String(rawLlm.llm_api_key || '').trim();
+ if (!legacy) return;
+
+ const provider = String(rawLlm.llm_provider || '').trim().toLowerCase();
+ if (!isLlmCloudProvider(provider)) return;
+
+ const field = llmProviderApiKeyField(provider);
+ if (state[field] && String(state[field]).trim() !== '') return;
+
+ state[field] = maskSecretForClient(legacy);
+ state[`${field}_masked`] = true;
+}
+
export async function loadSecrets(): Promise {
- const [llm, pipeline, google] = await Promise.all([
- loadLlmConfig(),
+ const [rawLlm, pipeline, google] = await Promise.all([
+ readLlmConfigRaw(),
loadPipelineConfig(),
loadGoogleAppSettings(),
]);
const state = buildInitialSecretsState();
-
- state.llm_api_key = String(llm.state.llm_api_key || '');
- if (llm.state.llm_api_key_masked) {
- state.llm_api_key_masked = true;
- }
+ loadLlmProviderSecrets(rawLlm, state);
for (const key of ALL_SECRETS_KEYS) {
const field = getSecretsFieldByKey(key);
@@ -80,19 +102,17 @@ export async function loadSecrets(): Promise {
}
export async function saveSecrets(rawState: SecretsState): Promise {
- const [llmLoaded, pipelineLoaded, googleLoaded] = await Promise.all([
- loadLlmConfig(),
+ const [pipelineLoaded, googleLoaded] = await Promise.all([
loadPipelineConfig(),
loadGoogleAppSettings(),
]);
- const llmState = { ...llmLoaded.state };
- if (rawState.llm_api_key !== undefined) {
- llmState.llm_api_key = String(rawState.llm_api_key ?? '');
- if (rawState.llm_api_key_masked === true) {
- llmState.llm_api_key_masked = true;
- } else {
- delete llmState.llm_api_key_masked;
+ const llmState: SecretsState = {};
+ for (const key of ALL_LLM_PROVIDER_API_KEY_KEYS) {
+ if (rawState[key] === undefined) continue;
+ llmState[key] = String(rawState[key] ?? '');
+ if (rawState[`${key}_masked`] === true) {
+ llmState[`${key}_masked`] = true;
}
}
diff --git a/web/src/strings.json b/web/src/strings.json
index d350518..e1eca51 100644
--- a/web/src/strings.json
+++ b/web/src/strings.json
@@ -849,7 +849,7 @@
"serviceAccountReplacePlaceholder": "Paste service account JSON to replace the saved key",
"googleConnectHint": "After saving Client ID and Secret, connect your Google account on",
"googleConnectLink": "Pipeline → Google",
- "aiProviderHint": "Choose provider and model on",
+ "aiProviderHint": "Save a key for each provider you use, then switch on",
"aiProviderLink": "Pipeline → Content & AI",
"pipelineBanner": "API keys and credentials are managed on the",
"googleConfigured": "Google Cloud credentials are configured.",
From 5e6fc97d4190201d4235dc918d95abb7513cbc53 Mon Sep 17 00:00:00 2001
From: PrashantUnity
Date: Wed, 17 Jun 2026 01:53:12 +0530
Subject: [PATCH 06/11] provider related stuff got fixed
---
README.md | 2 +-
docs/MCP.md | 2 +-
src/website_profiling/llm/agent.py | 23 +++-
src/website_profiling/llm/base.py | 21 +++
src/website_profiling/llm/providers/groq.py | 7 +-
src/website_profiling/llm/providers/openai.py | 5 +-
.../tools/audit_tools/registry.py | 37 +++++-
tests/test_llm_provider_groq.py | 8 ++
tests/tools/test_audit_tools.py | 9 ++
web/src/components/chat/ChatModelPicker.tsx | 60 ++++++---
.../components/chat/ChatProviderPicker.tsx | 123 ++++++++++++++++++
web/src/context/PipelineContext.tsx | 49 +++++++
web/src/lib/llmConfigSchema.ts | 2 +-
web/src/lib/llmProviderDefaults.test.ts | 35 +++++
web/src/lib/llmProviderDefaults.ts | 63 +++++++++
web/src/server/llmConfig.ts | 33 ++++-
web/src/strings.json | 3 +
web/src/views/Chat.tsx | 19 ++-
18 files changed, 455 insertions(+), 46 deletions(-)
create mode 100644 web/src/components/chat/ChatProviderPicker.tsx
create mode 100644 web/src/lib/llmProviderDefaults.test.ts
create mode 100644 web/src/lib/llmProviderDefaults.ts
diff --git a/README.md b/README.md
index bdb6688..35a0e51 100644
--- a/README.md
+++ b/README.md
@@ -221,7 +221,7 @@ Ask questions about audit data at [http://localhost:3000/chat](http://localhost:
| **Ollama** | Local daemon at `http://127.0.0.1:11434`. Chat UI lists installed models plus the live Ollama cloud catalog. Native tool calling when supported; ReAct fallback otherwise. |
| **OpenAI** / **Anthropic** | API key in AI settings or env (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`); native tool calling with streaming. |
| **Google Gemini** | API key in AI settings or `GEMINI_API_KEY`; REST via `httpx`. |
-| **Groq** | API key in AI settings or `GROQ_API_KEY`; official Groq Python SDK; native tool calling with streaming. Default model `llama-3.3-70b-versatile`. |
+| **Groq** | API key in AI settings or `GROQ_API_KEY`; official Groq Python SDK; native tool calling with streaming. Default model `openai/gpt-oss-120b`. |
The agent uses the same **340 read-only audit tools** as the MCP server ([docs/MCP.md](docs/MCP.md)), with **dynamic routing** (~45 tools per turn). Responses stream over SSE (`POST /api/chat`). Sessions persist per property (`chat_sessions` / `chat_messages`).
diff --git a/docs/MCP.md b/docs/MCP.md
index a3b3615..420bef6 100644
--- a/docs/MCP.md
+++ b/docs/MCP.md
@@ -239,7 +239,7 @@ Responses stream over SSE via `POST /api/chat`. Sessions persist per property in
| **OpenAI** | Native with streaming | API key in AI settings or `OPENAI_API_KEY` |
| **Anthropic** | Native with streaming | API key in AI settings or `ANTHROPIC_API_KEY` |
| **Google Gemini** | Native with streaming | API key in AI settings or `GEMINI_API_KEY`; REST via `httpx` |
-| **Groq** | Native with streaming | API key in AI settings or `GROQ_API_KEY`; official Groq Python SDK; default model `llama-3.3-70b-versatile` |
+| **Groq** | Native with streaming | API key in AI settings or `GROQ_API_KEY`; official Groq Python SDK; default model `openai/gpt-oss-120b` |
---
diff --git a/src/website_profiling/llm/agent.py b/src/website_profiling/llm/agent.py
index fc8cbbf..c9bb8db 100644
--- a/src/website_profiling/llm/agent.py
+++ b/src/website_profiling/llm/agent.py
@@ -8,7 +8,12 @@
from ..llm_config import llm_is_enabled, load_llm_config_from_db
from ..text_sanitize import sanitize_unicode_deep, strip_surrogates
from ..tools.audit_tools import AuditToolContext
-from ..tools.audit_tools.registry import TOOL_DEFINITIONS, dispatch_tool, openai_tools_schema
+from ..tools.audit_tools.registry import (
+ TOOL_DEFINITIONS,
+ _normalize_tool_args,
+ dispatch_tool,
+ openai_tools_schema,
+)
from ..tools.audit_tools.tool_selector import (
apply_tool_cap,
chat_tool_mode,
@@ -66,6 +71,7 @@
- When tools return issue lists, scores, or breakdowns, keep the narrative short. Do not re-list every issue or duplicate data in markdown tables—the UI renders structured blocks from tool data.
- Use markdown headings and bullets for structure. Do not emit fake chart JSON or custom visualization blocks.
- You are read-only: you cannot run crawls or change settings.
+- Do not pass property_id or report_id in tool calls — they are injected from the active chat property.
- If data is missing, say what integration or crawl step is needed.
"""
@@ -199,7 +205,7 @@ def run_agent_turn(
openai_messages = _build_openai_messages(messages)
last_user = _last_user_message(messages)
active_names = select_tools_for_turn(last_user, messages)
- tools = openai_tools_schema(active_names)
+ tools = openai_tools_schema(active_names, context_scoped=True)
tool_events: list[dict[str, Any]] = []
final_message = ""
@@ -225,7 +231,13 @@ def on_token(text: str) -> None:
)
except Exception as e:
msg = str(e).strip() or type(e).__name__
- if "httpx" in msg.lower() or "requirements.txt" in msg.lower():
+ if "Connection error" in msg and (cfg.get("llm_provider") or "").strip().lower() == "groq":
+ msg = (
+ "Could not reach Groq. Check your Groq API key on the Secrets page and "
+ "that outbound HTTPS to api.groq.com is allowed. "
+ f"Details: {msg}"
+ )
+ elif "httpx" in msg.lower() or "requirements.txt" in msg.lower():
msg = (
"LLM dependencies are missing. Run: pip install -r requirements.txt "
f"(or restart with ./local-run setup). Details: {msg}"
@@ -282,9 +294,10 @@ def _run_tool(tc: ToolCall) -> dict[str, Any]:
"error": f"tool not loaded this turn: {tc.name}",
"hint": "Call search_audit_tools to load specialized tools, or rephrase your request.",
}
+ tool_args = _normalize_tool_args(tc.arguments)
try:
return sanitize_unicode_deep(
- dispatch_tool(tc.name, tc.arguments, context=context),
+ dispatch_tool(tc.name, tool_args, context=context),
)
except Exception as e: # noqa: BLE001 - isolate one tool's failure from the batch
return {"error": str(e).strip() or type(e).__name__}
@@ -313,7 +326,7 @@ def _run_tool(tc: ToolCall) -> dict[str, Any]:
})
if gated:
- tools = openai_tools_schema(active_names)
+ tools = openai_tools_schema(active_names, context_scoped=True)
continue
final_message = strip_surrogates(result.content).strip()
diff --git a/src/website_profiling/llm/base.py b/src/website_profiling/llm/base.py
index 6d3d256..32ee099 100644
--- a/src/website_profiling/llm/base.py
+++ b/src/website_profiling/llm/base.py
@@ -23,6 +23,27 @@ class ChatResult:
TokenCallback = Callable[[str], None]
+OLLAMA_DEFAULT_BASES = frozenset({
+ "http://127.0.0.1:11434",
+ "http://localhost:11434",
+})
+
+
+def is_ollama_base_url(url: str) -> bool:
+ """True when llm_base_url points at a local Ollama daemon (not a cloud proxy)."""
+ normalized = (url or "").strip().rstrip("/").lower()
+ if normalized in OLLAMA_DEFAULT_BASES:
+ return True
+ return normalized.endswith(":11434")
+
+
+def optional_cloud_base_url(cfg: dict[str, str]) -> str | None:
+ """Custom OpenAI-compatible base URL; excludes Ollama's local default."""
+ base = (cfg.get("llm_base_url") or "").strip().rstrip("/")
+ if not base or is_ollama_base_url(base):
+ return None
+ return base
+
class LLMClient(Protocol):
def complete_json(self, system: str, user: str) -> dict[str, Any]: ...
diff --git a/src/website_profiling/llm/providers/groq.py b/src/website_profiling/llm/providers/groq.py
index 04ab6c8..9c0abb2 100644
--- a/src/website_profiling/llm/providers/groq.py
+++ b/src/website_profiling/llm/providers/groq.py
@@ -4,9 +4,9 @@
import json
from typing import Any
-from ..base import ChatResult, TokenCallback, ToolCall, parse_json_response
+from ..base import ChatResult, TokenCallback, ToolCall, optional_cloud_base_url, parse_json_response
-DEFAULT_MODEL = "llama-3.3-70b-versatile"
+DEFAULT_MODEL = "openai/gpt-oss-120b"
_MISSING_KEY_MSG = "Groq API key missing. Set it in the AI tab or GROQ_API_KEY."
@@ -15,8 +15,7 @@ def __init__(self, cfg: dict[str, str]) -> None:
self._model = (cfg.get("llm_model") or DEFAULT_MODEL).strip()
self._timeout = float(cfg.get("llm_timeout_s") or 120)
self._api_key = (cfg.get("llm_api_key") or "").strip()
- base = (cfg.get("llm_base_url") or "").strip().rstrip("/")
- self._base_url = base or None
+ self._base_url = optional_cloud_base_url(cfg)
def _client(self) -> Any:
if not self._api_key:
diff --git a/src/website_profiling/llm/providers/openai.py b/src/website_profiling/llm/providers/openai.py
index 583e592..dfa4af4 100644
--- a/src/website_profiling/llm/providers/openai.py
+++ b/src/website_profiling/llm/providers/openai.py
@@ -4,7 +4,7 @@
import json
from typing import Any
-from ..base import ChatResult, TokenCallback, ToolCall, parse_json_response
+from ..base import ChatResult, TokenCallback, ToolCall, optional_cloud_base_url, parse_json_response
class OpenAIClient:
@@ -13,7 +13,8 @@ def __init__(self, cfg: dict[str, str]) -> None:
self._model = (cfg.get("llm_model") or "gpt-4o-mini").strip()
self._timeout = float(cfg.get("llm_timeout_s") or 120)
self._api_key = (cfg.get("llm_api_key") or "").strip()
- self._base = (cfg.get("llm_base_url") or "https://api.openai.com/v1").strip().rstrip("/")
+ custom_base = optional_cloud_base_url(cfg)
+ self._base = custom_base or "https://api.openai.com/v1"
def complete_json(self, system: str, user: str) -> dict[str, Any]:
if not self._api_key:
diff --git a/src/website_profiling/tools/audit_tools/registry.py b/src/website_profiling/tools/audit_tools/registry.py
index c1b472f..6729156 100644
--- a/src/website_profiling/tools/audit_tools/registry.py
+++ b/src/website_profiling/tools/audit_tools/registry.py
@@ -1,6 +1,7 @@
"""Tool registry and dispatch for MCP and chat agent."""
from __future__ import annotations
+import copy
from typing import Any, Callable
from psycopg import Connection
@@ -760,6 +761,31 @@
}
+_CONTEXT_SCOPED_PARAMS = frozenset({"property_id", "report_id"})
+
+
+def _schema_for_llm(input_schema: dict[str, Any], *, context_scoped: bool) -> dict[str, Any]:
+ """Drop session-scoped IDs from LLM tool schemas (chat injects them from AuditToolContext)."""
+ if not context_scoped:
+ return input_schema
+ schema = copy.deepcopy(input_schema)
+ props = dict(schema.get("properties") or {})
+ for key in _CONTEXT_SCOPED_PARAMS:
+ props.pop(key, None)
+ schema["properties"] = props
+ schema["required"] = [
+ key for key in (schema.get("required") or []) if key not in _CONTEXT_SCOPED_PARAMS
+ ]
+ return schema
+
+
+def _normalize_tool_args(args: dict[str, Any] | None) -> dict[str, Any]:
+ """Remove explicit nulls so strict providers do not reject tool-call JSON."""
+ if not isinstance(args, dict):
+ return {}
+ return {key: value for key, value in args.items() if value is not None}
+
+
def dispatch_tool(
name: str,
args: dict[str, Any] | None,
@@ -773,7 +799,7 @@ def dispatch_tool(
return {"error": f"unknown tool: {name}"}
ctx = context or AuditToolContext()
- payload_args = dict(args or {})
+ payload_args = _normalize_tool_args(args)
merged_ctx = ctx.with_args(payload_args)
if conn is not None:
@@ -856,18 +882,23 @@ def search_tools(query: str, limit: int = 10) -> list[dict[str, Any]]:
return [row for _, _, row in scored[:cap]]
-def openai_tools_schema(names: set[str] | None = None) -> list[dict[str, Any]]:
+def openai_tools_schema(
+ names: set[str] | None = None,
+ *,
+ context_scoped: bool = False,
+) -> list[dict[str, Any]]:
"""Convert TOOL_DEFINITIONS to OpenAI function-calling format (optional name filter)."""
out: list[dict[str, Any]] = []
for tool in TOOL_DEFINITIONS:
if names is not None and tool["name"] not in names:
continue
+ input_schema = tool.get("inputSchema", {"type": "object", "properties": {}})
out.append({
"type": "function",
"function": {
"name": tool["name"],
"description": tool.get("description", ""),
- "parameters": tool.get("inputSchema", {"type": "object", "properties": {}}),
+ "parameters": _schema_for_llm(input_schema, context_scoped=context_scoped),
},
})
return out
diff --git a/tests/test_llm_provider_groq.py b/tests/test_llm_provider_groq.py
index 7112b22..5f3c4d3 100644
--- a/tests/test_llm_provider_groq.py
+++ b/tests/test_llm_provider_groq.py
@@ -40,6 +40,14 @@ def test_explicit_model_and_base_url() -> None:
assert client._base_url == "https://custom.example/v1"
+def test_ignores_ollama_base_url() -> None:
+ client = GroqClient({
+ "llm_api_key": "gsk-test",
+ "llm_base_url": "http://127.0.0.1:11434",
+ })
+ assert client._base_url is None
+
+
def test_complete_json_missing_key_raises_groq_error() -> None:
client = GroqClient({"llm_provider": "groq"})
with pytest.raises(RuntimeError, match="Groq API key"):
diff --git a/tests/tools/test_audit_tools.py b/tests/tools/test_audit_tools.py
index b203e67..7f9da2e 100644
--- a/tests/tools/test_audit_tools.py
+++ b/tests/tools/test_audit_tools.py
@@ -73,6 +73,15 @@ def test_openai_tools_schema() -> None:
assert schema[0]["type"] == "function"
+def test_openai_tools_schema_context_scoped_strips_property_id() -> None:
+ schema = openai_tools_schema({"run_technical_workflow"}, context_scoped=True)
+ assert len(schema) == 1
+ params = schema[0]["function"]["parameters"]
+ assert "property_id" not in params.get("properties", {})
+ assert "report_id" not in params.get("properties", {})
+ assert "property_id" not in (params.get("required") or [])
+
+
def test_dispatch_unknown_tool() -> None:
conn = MagicMock()
result = dispatch_tool("nonexistent", {}, conn=conn)
diff --git a/web/src/components/chat/ChatModelPicker.tsx b/web/src/components/chat/ChatModelPicker.tsx
index 2e1df1f..392b040 100644
--- a/web/src/components/chat/ChatModelPicker.tsx
+++ b/web/src/components/chat/ChatModelPicker.tsx
@@ -4,6 +4,11 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link';
import { Check, ChevronDown, Circle, Loader2, RefreshCw } from 'lucide-react';
import { useOllamaModels, type OllamaModelEntry } from '@/hooks/useOllamaModels';
+import {
+ cloudModelPresets,
+ effectiveLlmModel,
+ modelChipLabel,
+} from '@/lib/llmProviderDefaults';
import {
ollamaBillingLabel,
ollamaModelOptionLabel,
@@ -31,18 +36,20 @@ function groupModels(models: OllamaModelEntry[]) {
}
function ModelRow({
- entry,
+ label,
active,
onSelect,
+ hint,
}: {
- entry: OllamaModelEntry;
+ label: string;
active: boolean;
- onSelect: (name: string) => void;
+ onSelect: () => void;
+ hint?: string;
}) {
return (
);
@@ -74,6 +81,9 @@ export default function ChatModelPicker({
const [query, setQuery] = useState('');
const rootRef = useRef(null);
+ const effectiveModel = effectiveLlmModel(provider, model);
+ const cloudPresets = useMemo(() => cloudModelPresets(provider), [provider]);
+
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return models;
@@ -88,7 +98,7 @@ export default function ChatModelPicker({
? model
? ollamaModelShortLabel(model)
: c.ollamaNoModel
- : ollamaModelShortLabel(model || provider || 'AI');
+ : modelChipLabel(provider, effectiveModel) || c.cloudNoModel;
useEffect(() => {
if (!open) return;
@@ -169,9 +179,10 @@ export default function ChatModelPicker({
{installed.map((m) => (
void handleModelChange(name)}
+ onSelect={() => void handleModelChange(m.name)}
/>
))}
@@ -184,9 +195,10 @@ export default function ChatModelPicker({
{cloud.map((m) => (
void handleModelChange(name)}
+ onSelect={() => void handleModelChange(m.name)}
/>
))}
@@ -199,15 +211,27 @@ export default function ChatModelPicker({
{local.map((m) => (
void handleModelChange(name)}
+ onSelect={() => void handleModelChange(m.name)}
/>
))}
) : null}
>
+ ) : !isOllama && cloudPresets.length ? (
+
+ {cloudPresets.map((preset) => (
+ void handleModelChange(preset)}
+ />
+ ))}
+
) : (
{isOllama ? (
@@ -217,9 +241,7 @@ export default function ChatModelPicker({
{c.ollamaUnreachable}
)
) : (
-
- {model || provider}
-
+
{effectiveModel || c.cloudNoModel}
)}
)}
@@ -263,7 +285,7 @@ export default function ChatModelPicker({
)}
setOpen(false)}
>
diff --git a/web/src/components/chat/ChatProviderPicker.tsx b/web/src/components/chat/ChatProviderPicker.tsx
new file mode 100644
index 0000000..0b5385b
--- /dev/null
+++ b/web/src/components/chat/ChatProviderPicker.tsx
@@ -0,0 +1,123 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import Link from 'next/link';
+import { Check, ChevronDown } from 'lucide-react';
+import { LLM_PROVIDER_LABELS, LLM_CLOUD_PROVIDERS } from '@/lib/llmProviderApiKeys';
+import { strings } from '@/lib/strings';
+import { usePipeline } from '@/context/PipelineContext';
+
+const c = strings.components.chat;
+
+const CHAT_PROVIDER_OPTIONS = [
+ ...LLM_CLOUD_PROVIDERS.map((value) => ({
+ value,
+ label: LLM_PROVIDER_LABELS[value],
+ })),
+ { value: 'ollama', label: 'Ollama (local)' },
+];
+
+export interface ChatProviderPickerProps {
+ provider: string;
+ disabled?: boolean;
+}
+
+function providerLabel(value: string): string {
+ return CHAT_PROVIDER_OPTIONS.find((opt) => opt.value === value)?.label ?? value;
+}
+
+export default function ChatProviderPicker({ provider, disabled }: ChatProviderPickerProps) {
+ const { saveLlmProvider, saving } = usePipeline();
+ const [open, setOpen] = useState(false);
+ const [saveError, setSaveError] = useState('');
+ const rootRef = useRef(null);
+ const busy = disabled || saving;
+
+ useEffect(() => {
+ if (!open) return;
+ const onDocClick = (e: MouseEvent) => {
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
+ setOpen(false);
+ }
+ };
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') setOpen(false);
+ };
+ document.addEventListener('mousedown', onDocClick);
+ document.addEventListener('keydown', onKey);
+ return () => {
+ document.removeEventListener('mousedown', onDocClick);
+ document.removeEventListener('keydown', onKey);
+ };
+ }, [open]);
+
+ const handleProviderChange = async (nextProvider: string) => {
+ if (!nextProvider || nextProvider === provider) {
+ setOpen(false);
+ return;
+ }
+ setSaveError('');
+ const ok = await saveLlmProvider(nextProvider);
+ if (!ok) setSaveError(c.providerSaveFailed);
+ else setOpen(false);
+ };
+
+ return (
+
+
+
+ {open ? (
+
+
+ {CHAT_PROVIDER_OPTIONS.map((opt) => {
+ const active = opt.value === provider;
+ return (
+ -
+
+
+ );
+ })}
+
+
+
+ {saveError ?
{saveError}
: null}
+
+ setOpen(false)}
+ >
+ {c.aiSettingsLink}
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/web/src/context/PipelineContext.tsx b/web/src/context/PipelineContext.tsx
index 929e139..e88e9e4 100644
--- a/web/src/context/PipelineContext.tsx
+++ b/web/src/context/PipelineContext.tsx
@@ -31,6 +31,7 @@ import {
validateRequiredPipelineFields,
} from '@/lib/pipelineConfigSchema';
import { buildInitialLlmConfigState } from '@/lib/llmConfigSchema';
+import { defaultLlmModelForProvider } from '@/lib/llmProviderDefaults';
import {
applyPreset,
commandToPresetId,
@@ -87,6 +88,7 @@ export interface PipelineContextValue {
loadConfig: () => Promise;
saveSettings: () => Promise;
saveLlmModel: (model: string) => Promise;
+ saveLlmProvider: (provider: string) => Promise;
run: () => Promise;
cancelJob: () => Promise;
continueInBackground: () => void;
@@ -493,6 +495,51 @@ export function PipelineProvider({ children }: { children: ReactNode }) {
[buildLlmPayload],
);
+ const saveLlmProvider = useCallback(
+ async (provider: string): Promise => {
+ const trimmed = provider.trim();
+ if (!trimmed || trimmed === 'none') return false;
+ setLlmConfigState((prev) => {
+ const providerChanged = trimmed !== String(prev.llm_provider || '');
+ const nextModel = providerChanged
+ ? defaultLlmModelForProvider(trimmed)
+ : String(prev.llm_model || '');
+ return {
+ ...prev,
+ llm_provider: trimmed,
+ llm_model: nextModel,
+ };
+ });
+ setSaving(true);
+ try {
+ const payload = buildLlmPayload();
+ const providerChanged = trimmed !== String(payload.llm_provider || '');
+ const nextModel = providerChanged
+ ? defaultLlmModelForProvider(trimmed)
+ : String(payload.llm_model || '');
+ const res = await fetch(apiUrl('/llm-config'), {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ state: {
+ ...payload,
+ llm_provider: trimmed,
+ llm_model: nextModel,
+ },
+ }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(data.error || res.statusText);
+ return true;
+ } catch {
+ return false;
+ } finally {
+ setSaving(false);
+ }
+ },
+ [buildLlmPayload],
+ );
+
const run = useCallback(async () => {
const command = effectiveCommand || null;
let browserStatus = browserCrawlStatus;
@@ -644,6 +691,7 @@ export function PipelineProvider({ children }: { children: ReactNode }) {
loadConfig,
saveSettings,
saveLlmModel,
+ saveLlmProvider,
run,
cancelJob,
continueInBackground,
@@ -682,6 +730,7 @@ export function PipelineProvider({ children }: { children: ReactNode }) {
loadConfig,
saveSettings,
saveLlmModel,
+ saveLlmProvider,
run,
cancelJob,
continueInBackground,
diff --git a/web/src/lib/llmConfigSchema.ts b/web/src/lib/llmConfigSchema.ts
index b7af5b4..76a0283 100644
--- a/web/src/lib/llmConfigSchema.ts
+++ b/web/src/lib/llmConfigSchema.ts
@@ -35,7 +35,7 @@ export const LLM_CONFIG_SECTIONS = [
label: 'Model',
type: 'text',
defaultValue: '',
- placeholder: 'e.g. gpt-4o-mini, gemini-2.0-flash, claude-3-5-haiku-latest, llama-3.3-70b-versatile, llama3.2',
+ placeholder: 'e.g. gpt-4o-mini, gemini-2.0-flash, claude-3-5-haiku-latest, openai/gpt-oss-120b, llama3.2',
help: 'Leave blank to use provider default.',
},
{
diff --git a/web/src/lib/llmProviderDefaults.test.ts b/web/src/lib/llmProviderDefaults.test.ts
new file mode 100644
index 0000000..b91b255
--- /dev/null
+++ b/web/src/lib/llmProviderDefaults.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest';
+import {
+ cloudModelPresets,
+ defaultLlmModelForProvider,
+ effectiveLlmModel,
+ ensurePersistedLlmModel,
+ modelChipLabel,
+} from '@/lib/llmProviderDefaults';
+
+describe('llmProviderDefaults', () => {
+ it('returns groq default model', () => {
+ expect(defaultLlmModelForProvider('groq')).toBe('openai/gpt-oss-120b');
+ });
+
+ it('uses effective model when stored model is blank', () => {
+ expect(effectiveLlmModel('groq', '')).toBe('openai/gpt-oss-120b');
+ expect(effectiveLlmModel('groq', 'llama-3.1-8b-instant')).toBe('llama-3.1-8b-instant');
+ });
+
+ it('shortens groq model chip label', () => {
+ expect(modelChipLabel('groq', 'openai/gpt-oss-120b')).toBe('gpt-oss-120b');
+ });
+
+ it('includes groq presets with default first', () => {
+ expect(cloudModelPresets('groq')[0]).toBe('openai/gpt-oss-120b');
+ });
+
+ it('fills empty llm_model before database save', () => {
+ const entries = ensurePersistedLlmModel({
+ llm_provider: 'groq',
+ llm_model: '',
+ });
+ expect(entries.llm_model).toBe('openai/gpt-oss-120b');
+ });
+});
diff --git a/web/src/lib/llmProviderDefaults.ts b/web/src/lib/llmProviderDefaults.ts
new file mode 100644
index 0000000..fac919f
--- /dev/null
+++ b/web/src/lib/llmProviderDefaults.ts
@@ -0,0 +1,63 @@
+/** Default models per provider — keep in sync with Python LLM clients. */
+export const DEFAULT_LLM_MODEL_BY_PROVIDER: Record = {
+ openai: 'gpt-4o-mini',
+ gemini: 'gemini-2.0-flash',
+ anthropic: 'claude-3-5-haiku-latest',
+ groq: 'openai/gpt-oss-120b',
+ ollama: 'llama3.2',
+};
+
+/** Common models offered in the chat composer for cloud providers. */
+export const LLM_MODEL_PRESETS: Record = {
+ groq: [
+ 'openai/gpt-oss-120b',
+ 'llama-3.3-70b-versatile',
+ 'llama-3.1-8b-instant',
+ ],
+ openai: ['gpt-4o-mini', 'gpt-4o', 'gpt-4.1-mini'],
+ gemini: ['gemini-2.0-flash', 'gemini-2.5-flash-preview'],
+ anthropic: ['claude-3-5-haiku-latest', 'claude-3-5-sonnet-latest'],
+};
+
+export function defaultLlmModelForProvider(provider: string): string {
+ return DEFAULT_LLM_MODEL_BY_PROVIDER[provider] ?? '';
+}
+
+export function effectiveLlmModel(provider: string, model: string | undefined): string {
+ const trimmed = String(model ?? '').trim();
+ if (trimmed) return trimmed;
+ return defaultLlmModelForProvider(provider);
+}
+
+export function cloudModelPresets(provider: string): string[] {
+ const presets = LLM_MODEL_PRESETS[provider];
+ if (!presets?.length) return [];
+ const defaultModel = defaultLlmModelForProvider(provider);
+ const merged = defaultModel && !presets.includes(defaultModel)
+ ? [defaultModel, ...presets]
+ : [...presets];
+ return [...new Set(merged)];
+}
+
+export function modelChipLabel(provider: string, model: string | undefined): string {
+ const effective = effectiveLlmModel(provider, model);
+ if (!effective) return '';
+ const slash = effective.lastIndexOf('/');
+ if (slash >= 0 && slash < effective.length - 1) {
+ return effective.slice(slash + 1);
+ }
+ return effective.length > 20 ? `${effective.slice(0, 19)}…` : effective;
+}
+
+/** Never persist an empty llm_model when a provider is selected. */
+export function ensurePersistedLlmModel(entries: Record): Record {
+ const out = { ...entries };
+ const provider = String(out.llm_provider || 'none');
+ if (!String(out.llm_model || '').trim() && provider !== 'none') {
+ const defaultModel = defaultLlmModelForProvider(provider);
+ if (defaultModel) {
+ out.llm_model = defaultModel;
+ }
+ }
+ return out;
+}
diff --git a/web/src/server/llmConfig.ts b/web/src/server/llmConfig.ts
index 329d0b5..b4a372d 100644
--- a/web/src/server/llmConfig.ts
+++ b/web/src/server/llmConfig.ts
@@ -15,6 +15,7 @@ import {
isLlmProviderApiKeyField,
resolveLlmApiKey,
} from '@/lib/llmProviderApiKeys';
+import { defaultLlmModelForProvider, ensurePersistedLlmModel } from '@/lib/llmProviderDefaults';
import { withDb } from '@/server/db';
import type { LlmConfigLoadResult, LlmConfigState } from '@/types/api';
@@ -98,7 +99,7 @@ export async function readLlmConfigRaw(): Promise> {
}
export async function loadLlmConfig(): Promise {
- return withDb(async (client: PoolClient) => {
+ const loaded = await withDb(async (client: PoolClient) => {
const known = await readLlmConfigFromDb(client);
if (Object.keys(known).length > 0) {
const state = applyLlmDefaults(known);
@@ -107,10 +108,32 @@ export async function loadLlmConfig(): Promise {
if (resolved) {
state.llm_api_key = resolved;
}
- return { state: maskLlmStateForClient(state), source: 'store' };
+ const provider = String(state.llm_provider || 'none');
+ const dbModelEmpty = !String(known.llm_model || '').trim();
+ if (!String(state.llm_model || '').trim() && provider !== 'none') {
+ state.llm_model = defaultLlmModelForProvider(provider);
+ }
+ return {
+ state,
+ source: 'store' as const,
+ backfillModel: dbModelEmpty && provider !== 'none',
+ };
}
- return { state: maskLlmStateForClient(buildInitialLlmConfigState()), source: 'defaults' };
+ return {
+ state: buildInitialLlmConfigState(),
+ source: 'defaults' as const,
+ backfillModel: false,
+ };
});
+
+ if (loaded.backfillModel) {
+ await saveLlmConfig(loaded.state);
+ }
+
+ return {
+ state: maskLlmStateForClient(loaded.state),
+ source: loaded.source,
+ };
}
export interface SaveLlmConfigOptions {
@@ -161,12 +184,14 @@ export async function saveLlmConfig(
}
}
+ const persistedEntries = ensurePersistedLlmModel(entries);
+
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
await client.query('BEGIN');
try {
await client.query('DELETE FROM llm_config');
- for (const [k, v] of Object.entries(entries)) {
+ for (const [k, v] of Object.entries(persistedEntries)) {
await client.query(
'INSERT INTO llm_config (key, value, is_secret, updated_at) VALUES ($1, $2, $3, $4)',
[k, v, secretKeys.has(k), now],
diff --git a/web/src/strings.json b/web/src/strings.json
index e1eca51..5488ee2 100644
--- a/web/src/strings.json
+++ b/web/src/strings.json
@@ -3827,6 +3827,7 @@
"agentError": "The assistant encountered an error",
"ollamaLabel": "Ollama",
"ollamaNoModel": "no model selected",
+ "cloudNoModel": "no model selected",
"ollamaUnreachable": "Cannot reach Ollama",
"fabTitle": "AI Chat",
"fabDragTitle": "AI Chat — drag to move to any corner",
@@ -3839,9 +3840,11 @@
"ollamaBillingCloudFree": "Account · free tier",
"ollamaBillingPro": "Pro plan",
"chooseModel": "Choose model",
+ "chooseProvider": "Choose provider",
"findModel": "Find model",
"changeModel": "Change in AI settings",
"modelSaveFailed": "Could not save model selection",
+ "providerSaveFailed": "Could not save provider selection",
"selectedModelBilling": "Active model",
"ollamaRefresh": "Refresh Ollama status",
"queryingData": "Querying audit data…",
diff --git a/web/src/views/Chat.tsx b/web/src/views/Chat.tsx
index efeb281..981d817 100644
--- a/web/src/views/Chat.tsx
+++ b/web/src/views/Chat.tsx
@@ -11,6 +11,7 @@ import ChatMessageList, { type ChatMessage } from '@/components/chat/ChatMessage
import ChatComposer from '@/components/chat/ChatComposer';
import SuggestedPrompts from '@/components/chat/SuggestedPrompts';
import ChatModelPicker from '@/components/chat/ChatModelPicker';
+import ChatProviderPicker from '@/components/chat/ChatProviderPicker';
import ChatActivityBar from '@/components/chat/ChatActivityBar';
import { ChatFollowUpProvider } from '@/components/chat/ChatFollowUpContext';
import { usePipeline } from '@/context/PipelineContext';
@@ -449,12 +450,18 @@ export default function ChatPage() {
};
const modelPicker = llmEnabled ? (
-
+ <>
+
+
+ >
) : null;
const composer = (
From a1177e4d890f4209d503ac042b781a56f9def6bc Mon Sep 17 00:00:00 2001
From: PrashantUnity
Date: Wed, 17 Jun 2026 02:03:06 +0530
Subject: [PATCH 07/11] infinite mode tools calling
---
src/website_profiling/llm/agent.py | 49 +++++++++++++++++--
tests/test_chat_agent.py | 18 ++++++-
web/app/api/chat/route.ts | 48 ++++++++++++++++--
.../chat/ChatUnlimitedToolsToggle.tsx | 36 ++++++++++++++
web/src/components/chat/parseChatSse.ts | 3 ++
web/src/context/PipelineContext.tsx | 27 ++++++++++
web/src/lib/llmConfigSchema.ts | 13 +++++
web/src/strings.json | 6 +++
web/src/views/Chat.tsx | 19 ++++++-
9 files changed, 208 insertions(+), 11 deletions(-)
create mode 100644 web/src/components/chat/ChatUnlimitedToolsToggle.tsx
diff --git a/src/website_profiling/llm/agent.py b/src/website_profiling/llm/agent.py
index c9bb8db..1473ee9 100644
--- a/src/website_profiling/llm/agent.py
+++ b/src/website_profiling/llm/agent.py
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
+import os
from typing import Any, Callable
from ..concurrency import map_parallel, tool_concurrency
@@ -22,7 +23,33 @@
)
from .base import ChatResult, ToolCall, get_llm_client
-MAX_TOOL_ROUNDS = 10
+MAX_TOOL_ROUNDS_DEFAULT = 10
+MAX_TOOL_ROUNDS_EXTENDED = 100
+# Back-compat for tests and imports
+MAX_TOOL_ROUNDS = MAX_TOOL_ROUNDS_DEFAULT
+
+
+def _truthy_cfg(cfg: dict[str, str], key: str) -> bool:
+ return str(cfg.get(key, "")).lower() in ("true", "1", "yes")
+
+
+def _max_tool_rounds(cfg: dict[str, str]) -> int:
+ """Resolve per-turn tool loop cap from llm_config and optional env overrides."""
+ if _truthy_cfg(cfg, "llm_chat_unlimited_tool_rounds"):
+ raw = (os.environ.get("CHAT_MAX_TOOL_ROUNDS_EXTENDED") or "").strip()
+ if raw:
+ try:
+ return max(1, int(raw))
+ except ValueError:
+ pass
+ return MAX_TOOL_ROUNDS_EXTENDED
+ raw = (os.environ.get("CHAT_MAX_TOOL_ROUNDS") or "").strip()
+ if raw:
+ try:
+ return max(1, int(raw))
+ except ValueError:
+ pass
+ return MAX_TOOL_ROUNDS_DEFAULT
SYSTEM_PROMPT = """You are Site Audit AI, a technical SEO assistant for a self-hosted site audit platform.
You help users understand crawl results, audit issues, Lighthouse scores, keywords, and Search Console data.
@@ -208,15 +235,16 @@ def run_agent_turn(
tools = openai_tools_schema(active_names, context_scoped=True)
tool_events: list[dict[str, Any]] = []
final_message = ""
+ max_rounds = _max_tool_rounds(cfg)
def on_token(text: str) -> None:
_emit(on_event, {"type": "token", "text": strip_surrogates(text)})
- for _round in range(MAX_TOOL_ROUNDS):
+ for _round in range(max_rounds):
_emit(on_event, {
"type": "status",
"phase": "model",
- "detail": f"Thinking (step {_round + 1}/{MAX_TOOL_ROUNDS})…",
+ "detail": f"Thinking (step {_round + 1}/{max_rounds})…",
})
try:
llm_messages = sanitize_unicode_deep(openai_messages)
@@ -337,5 +365,18 @@ def _run_tool(tc: ToolCall) -> dict[str, Any]:
break
err = "Agent stopped after maximum tool rounds without a final answer."
+ partial = final_message
+ if not partial and tool_events:
+ partial = (
+ f"The agent completed {len(tool_events)} tool step(s) but did not finish "
+ "with a final summary. Tool results are preserved below."
+ )
+ if partial:
+ _emit(on_event, {"type": "partial_done", "message": partial})
_emit(on_event, {"type": "error", "message": err})
- return {"ok": False, "error": err, "tool_events": tool_events}
+ return {
+ "ok": False,
+ "error": err,
+ "message": partial,
+ "tool_events": tool_events,
+ }
diff --git a/tests/test_chat_agent.py b/tests/test_chat_agent.py
index 908e2a2..2d8b1a8 100644
--- a/tests/test_chat_agent.py
+++ b/tests/test_chat_agent.py
@@ -3,7 +3,12 @@
from unittest.mock import MagicMock, patch
-from website_profiling.llm.agent import MAX_TOOL_ROUNDS, run_agent_turn
+from website_profiling.llm.agent import (
+ MAX_TOOL_ROUNDS,
+ MAX_TOOL_ROUNDS_EXTENDED,
+ _max_tool_rounds,
+ run_agent_turn,
+)
from website_profiling.llm.base import ChatResult, ToolCall
from website_profiling.tools.audit_tools import AuditToolContext
@@ -148,9 +153,11 @@ def test_max_tool_rounds() -> None:
)
client = FakeToolClient([always_tool] * (MAX_TOOL_ROUNDS + 1))
ctx = AuditToolContext()
+ events: list[dict] = []
with patch("website_profiling.llm.agent.load_llm_config_from_db", return_value={
"llm_enabled": True, "llm_provider": "openai", "llm_api_key": "sk-test",
+ "llm_chat_unlimited_tool_rounds": "false",
}):
with patch("website_profiling.llm.agent.get_llm_client", return_value=client):
with patch(
@@ -160,7 +167,16 @@ def test_max_tool_rounds() -> None:
result = run_agent_turn(
[{"role": "user", "content": "List properties"}],
ctx,
+ on_event=events.append,
)
assert result["ok"] is False
assert "maximum tool rounds" in result["error"].lower()
+ assert result.get("message")
+ assert events[-1]["type"] == "error"
+ assert any(e["type"] == "partial_done" for e in events)
+
+
+def test_max_tool_rounds_extended_when_unlimited_enabled() -> None:
+ assert _max_tool_rounds({"llm_chat_unlimited_tool_rounds": "true"}) == MAX_TOOL_ROUNDS_EXTENDED
+ assert _max_tool_rounds({"llm_chat_unlimited_tool_rounds": "false"}) == MAX_TOOL_ROUNDS
diff --git a/web/app/api/chat/route.ts b/web/app/api/chat/route.ts
index 89d60f8..a87684c 100644
--- a/web/app/api/chat/route.ts
+++ b/web/app/api/chat/route.ts
@@ -46,6 +46,25 @@ function sseLine(event: string, data: Record): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
}
+function buildPersistedAssistantContent(
+ assistantText: string,
+ toolEvents: Array<{ name: string; args?: Record; result?: Record }>,
+ sawError: boolean,
+ lastErrorMessage: string,
+): string | null {
+ const text = assistantText.trim();
+ if (text) return text;
+ if (toolEvents.length > 0) {
+ return sawError
+ ? 'Tool results were saved from this turn. The assistant did not produce a final summary.'
+ : 'Tool results from this turn are shown below.';
+ }
+ if (sawError && lastErrorMessage.trim()) {
+ return lastErrorMessage.trim();
+ }
+ return null;
+}
+
/** POST /api/chat — stream agent response via SSE. */
export const POST: ApiRouteHandler = async (request: NextRequest): Promise => {
const denied = forbiddenIfNotLocal(request);
@@ -99,6 +118,7 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise;
@@ -121,7 +141,10 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise) => {
if (closed) return;
- if (event === 'error') sawError = true;
+ if (event === 'error') {
+ sawError = true;
+ lastErrorMessage = String(data.message || 'Agent error');
+ }
try {
controller.enqueue(encoder.encode(sseLine(event, data)));
} catch {
@@ -200,6 +223,9 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise 60 ? '…' : '');
diff --git a/web/src/components/chat/ChatUnlimitedToolsToggle.tsx b/web/src/components/chat/ChatUnlimitedToolsToggle.tsx
new file mode 100644
index 0000000..e71e2e5
--- /dev/null
+++ b/web/src/components/chat/ChatUnlimitedToolsToggle.tsx
@@ -0,0 +1,36 @@
+'use client';
+
+import { Infinity } from 'lucide-react';
+import { strings } from '@/lib/strings';
+import { usePipeline } from '@/context/PipelineContext';
+
+const c = strings.components.chat;
+
+export interface ChatUnlimitedToolsToggleProps {
+ disabled?: boolean;
+}
+
+export default function ChatUnlimitedToolsToggle({ disabled }: ChatUnlimitedToolsToggleProps) {
+ const { llmConfigState, saveLlmChatUnlimitedTools, saving } = usePipeline();
+ const enabled = llmConfigState.llm_chat_unlimited_tool_rounds === true;
+ const busy = disabled || saving;
+
+ return (
+
+ );
+}
diff --git a/web/src/components/chat/parseChatSse.ts b/web/src/components/chat/parseChatSse.ts
index 4bb5c96..716f940 100644
--- a/web/src/components/chat/parseChatSse.ts
+++ b/web/src/components/chat/parseChatSse.ts
@@ -4,6 +4,7 @@ export type ChatSseEvent =
| { type: 'tool_start'; name?: string; args?: Record }
| { type: 'tool_end'; name?: string; result?: Record }
| { type: 'done'; message?: string }
+ | { type: 'partial_done'; message?: string }
| { type: 'error'; message?: string };
export function parseSseChunk(buffer: string): { events: ChatSseEvent[]; rest: string } {
@@ -47,6 +48,8 @@ export function parseSseChunk(buffer: string): { events: ChatSseEvent[]; rest: s
});
} else if (eventType === 'done') {
events.push({ type: 'done', message: String(data.message || '') });
+ } else if (eventType === 'partial_done') {
+ events.push({ type: 'partial_done', message: String(data.message || '') });
} else if (eventType === 'error') {
events.push({ type: 'error', message: String(data.message || 'Error') });
}
diff --git a/web/src/context/PipelineContext.tsx b/web/src/context/PipelineContext.tsx
index e88e9e4..95fad98 100644
--- a/web/src/context/PipelineContext.tsx
+++ b/web/src/context/PipelineContext.tsx
@@ -89,6 +89,7 @@ export interface PipelineContextValue {
saveSettings: () => Promise;
saveLlmModel: (model: string) => Promise;
saveLlmProvider: (provider: string) => Promise;
+ saveLlmChatUnlimitedTools: (enabled: boolean) => Promise;
run: () => Promise;
cancelJob: () => Promise;
continueInBackground: () => void;
@@ -540,6 +541,30 @@ export function PipelineProvider({ children }: { children: ReactNode }) {
[buildLlmPayload],
);
+ const saveLlmChatUnlimitedTools = useCallback(
+ async (enabled: boolean): Promise => {
+ setLlmConfigState((prev) => ({ ...prev, llm_chat_unlimited_tool_rounds: enabled }));
+ setSaving(true);
+ try {
+ const res = await fetch(apiUrl('/llm-config'), {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ state: { ...buildLlmPayload(), llm_chat_unlimited_tool_rounds: enabled },
+ }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(data.error || res.statusText);
+ return true;
+ } catch {
+ return false;
+ } finally {
+ setSaving(false);
+ }
+ },
+ [buildLlmPayload],
+ );
+
const run = useCallback(async () => {
const command = effectiveCommand || null;
let browserStatus = browserCrawlStatus;
@@ -692,6 +717,7 @@ export function PipelineProvider({ children }: { children: ReactNode }) {
saveSettings,
saveLlmModel,
saveLlmProvider,
+ saveLlmChatUnlimitedTools,
run,
cancelJob,
continueInBackground,
@@ -731,6 +757,7 @@ export function PipelineProvider({ children }: { children: ReactNode }) {
saveSettings,
saveLlmModel,
saveLlmProvider,
+ saveLlmChatUnlimitedTools,
run,
cancelJob,
continueInBackground,
diff --git a/web/src/lib/llmConfigSchema.ts b/web/src/lib/llmConfigSchema.ts
index 76a0283..ed0ebc1 100644
--- a/web/src/lib/llmConfigSchema.ts
+++ b/web/src/lib/llmConfigSchema.ts
@@ -92,6 +92,19 @@ export const LLM_CONFIG_SECTIONS = [
},
],
},
+ {
+ id: 'llm_chat',
+ label: 'Chat agent',
+ fields: [
+ {
+ key: 'llm_chat_unlimited_tool_rounds',
+ label: 'Extended tool rounds (chat)',
+ type: 'bool',
+ defaultValue: false,
+ help: 'When enabled, the chat agent may run up to 100 tool steps per message instead of 10. Use for deep multi-step audits.',
+ },
+ ],
+ },
{
id: 'llm_limits',
label: 'Limits',
diff --git a/web/src/strings.json b/web/src/strings.json
index 5488ee2..38cd8a5 100644
--- a/web/src/strings.json
+++ b/web/src/strings.json
@@ -3841,6 +3841,12 @@
"ollamaBillingPro": "Pro plan",
"chooseModel": "Choose model",
"chooseProvider": "Choose provider",
+ "unlimitedToolsShort": "Deep tools",
+ "unlimitedToolsOnLabel": "Extended tool rounds enabled",
+ "unlimitedToolsOffLabel": "Enable extended tool rounds",
+ "unlimitedToolsOnHint": "Up to 100 tool steps per message (default is 10). Click to use standard limit.",
+ "unlimitedToolsOffHint": "Allow up to 100 tool steps per message for deep multi-step audits.",
+ "partialToolsSaved": "Tool results were saved from this turn. The assistant did not produce a final summary.",
"findModel": "Find model",
"changeModel": "Change in AI settings",
"modelSaveFailed": "Could not save model selection",
diff --git a/web/src/views/Chat.tsx b/web/src/views/Chat.tsx
index 981d817..e156c59 100644
--- a/web/src/views/Chat.tsx
+++ b/web/src/views/Chat.tsx
@@ -12,6 +12,7 @@ import ChatComposer from '@/components/chat/ChatComposer';
import SuggestedPrompts from '@/components/chat/SuggestedPrompts';
import ChatModelPicker from '@/components/chat/ChatModelPicker';
import ChatProviderPicker from '@/components/chat/ChatProviderPicker';
+import ChatUnlimitedToolsToggle from '@/components/chat/ChatUnlimitedToolsToggle';
import ChatActivityBar from '@/components/chat/ChatActivityBar';
import { ChatFollowUpProvider } from '@/components/chat/ChatFollowUpContext';
import { usePipeline } from '@/context/PipelineContext';
@@ -387,15 +388,28 @@ export default function ChatPage() {
} else if (evt.type === 'done' && evt.message) {
content = evt.message;
patchAssistant({ content: evt.message, streaming: true, error: false });
+ } else if (evt.type === 'partial_done' && evt.message) {
+ content = evt.message;
+ patchAssistant({
+ content: evt.message,
+ streaming: true,
+ error: false,
+ toolActivity: tools,
+ blocks: deriveChatBlocks(tools),
+ });
} else if (evt.type === 'error') {
streamError = evt.message || c.agentError;
setError(streamError);
+ const fallbackContent =
+ content.trim() ||
+ (tools.length > 0 ? c.partialToolsSaved : streamError);
patchAssistant({
- content: streamError,
+ content: fallbackContent,
streaming: false,
error: true,
statusText: undefined,
toolActivity: tools,
+ blocks: deriveChatBlocks(tools),
});
}
});
@@ -420,8 +434,8 @@ export default function ChatPage() {
blocks: deriveChatBlocks(tools),
});
}
- if (sid) await loadMessages(sid);
}
+ if (sid) await loadMessages(sid);
if (propertyId) await loadSessions(propertyId);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -451,6 +465,7 @@ export default function ChatPage() {
const modelPicker = llmEnabled ? (
<>
+
Date: Wed, 17 Jun 2026 02:22:56 +0530
Subject: [PATCH 08/11] little bit formatting chat page
---
src/website_profiling/llm/agent.py | 11 ++
tests/test_chat_agent.py | 8 +
web/app/chunk-load-recovery.tsx | 37 ++++
web/app/globals.css | 36 ++++
web/app/layout.tsx | 7 +-
.../components/chat/ChatAssistantMessage.tsx | 104 ++++++++++
.../chat/ChatInsightSections.test.ts | 27 +++
.../components/chat/ChatInsightSections.tsx | 133 +++++++++++++
web/src/components/chat/ChatMarkdown.tsx | 7 +-
web/src/components/chat/ChatMessageList.tsx | 90 ++++-----
web/src/components/chat/ChatToolActivity.tsx | 82 ++++++--
web/src/components/chat/blocks/ChatBlocks.tsx | 5 +
.../chat/blocks/ChatToolStatusBlock.tsx | 49 +++++
web/src/components/chat/chatSectionTitles.ts | 56 ++++++
web/src/components/chat/deriveChatBlocks.ts | 17 ++
.../chat/deriveFallbackBlocks.test.ts | 46 +++++
.../components/chat/deriveFallbackBlocks.ts | 184 ++++++++++++++++++
.../chat/postprocessChatContent.test.ts | 78 ++++++++
.../components/chat/postprocessChatContent.ts | 80 ++++++++
.../chat/preprocessChatMarkdown.test.ts | 28 +++
.../components/chat/preprocessChatMarkdown.ts | 143 ++++++++++++--
.../components/chat/sanitizeChatProse.test.ts | 90 +++++++++
web/src/components/chat/sanitizeChatProse.ts | 104 ++++++++++
.../chat/stripRedundantMarkdown.test.ts | 15 ++
.../components/chat/stripRedundantMarkdown.ts | 155 +++++++++++++--
web/src/strings.json | 12 ++
web/src/views/Chat.tsx | 36 ++--
27 files changed, 1525 insertions(+), 115 deletions(-)
create mode 100644 web/app/chunk-load-recovery.tsx
create mode 100644 web/src/components/chat/ChatAssistantMessage.tsx
create mode 100644 web/src/components/chat/ChatInsightSections.test.ts
create mode 100644 web/src/components/chat/ChatInsightSections.tsx
create mode 100644 web/src/components/chat/blocks/ChatToolStatusBlock.tsx
create mode 100644 web/src/components/chat/chatSectionTitles.ts
create mode 100644 web/src/components/chat/deriveFallbackBlocks.test.ts
create mode 100644 web/src/components/chat/deriveFallbackBlocks.ts
create mode 100644 web/src/components/chat/postprocessChatContent.test.ts
create mode 100644 web/src/components/chat/postprocessChatContent.ts
create mode 100644 web/src/components/chat/sanitizeChatProse.test.ts
create mode 100644 web/src/components/chat/sanitizeChatProse.ts
diff --git a/src/website_profiling/llm/agent.py b/src/website_profiling/llm/agent.py
index 1473ee9..d82b78d 100644
--- a/src/website_profiling/llm/agent.py
+++ b/src/website_profiling/llm/agent.py
@@ -97,6 +97,17 @@ def _max_tool_rounds(cfg: dict[str, str]) -> int:
- For visual or chart requests, always call the appropriate tools first, then give a short interpretation (2–4 sentences) with recommendations.
- When tools return issue lists, scores, or breakdowns, keep the narrative short. Do not re-list every issue or duplicate data in markdown tables—the UI renders structured blocks from tool data.
- Use markdown headings and bullets for structure. Do not emit fake chart JSON or custom visualization blocks.
+- After tool calls that return summary data, respond using this template only (no emojis in headings, max 5 bullets/items per section, total prose under ~150 words):
+
+### Power Insights
+- (interpretation bullets)
+
+### Recommended actions
+1. (numbered actions)
+
+- Do not repeat health scores, URL counts, success rates, category scores, priority counts, or URL lists in prose when the UI already shows them in cards or tables.
+- Never mention internal tool names (e.g. run_technical_workflow, export_audit_report) in user-facing text — describe actions in plain language ("run a technical workflow", "export the audit as PDF").
+- Do not use pipe-table rows for category scores; the UI renders category score cards automatically.
- You are read-only: you cannot run crawls or change settings.
- Do not pass property_id or report_id in tool calls — they are injected from the active chat property.
- If data is missing, say what integration or crawl step is needed.
diff --git a/tests/test_chat_agent.py b/tests/test_chat_agent.py
index 2d8b1a8..e50fa34 100644
--- a/tests/test_chat_agent.py
+++ b/tests/test_chat_agent.py
@@ -180,3 +180,11 @@ def test_max_tool_rounds() -> None:
def test_max_tool_rounds_extended_when_unlimited_enabled() -> None:
assert _max_tool_rounds({"llm_chat_unlimited_tool_rounds": "true"}) == MAX_TOOL_ROUNDS_EXTENDED
assert _max_tool_rounds({"llm_chat_unlimited_tool_rounds": "false"}) == MAX_TOOL_ROUNDS
+
+
+def test_system_prompt_has_output_template() -> None:
+ from website_profiling.llm.agent import SYSTEM_PROMPT
+
+ assert "### Power Insights" in SYSTEM_PROMPT
+ assert "### Recommended actions" in SYSTEM_PROMPT
+ assert "no emojis in headings" in SYSTEM_PROMPT.lower()
diff --git a/web/app/chunk-load-recovery.tsx b/web/app/chunk-load-recovery.tsx
new file mode 100644
index 0000000..56db3fb
--- /dev/null
+++ b/web/app/chunk-load-recovery.tsx
@@ -0,0 +1,37 @@
+'use client';
+
+import { useEffect } from 'react';
+
+const RELOAD_KEY = 'wp-chunk-reload-once';
+
+/**
+ * Dev/HMR sometimes serves stale chunk URLs; one automatic reload usually fixes ChunkLoadError.
+ */
+export default function ChunkLoadRecovery() {
+ useEffect(() => {
+ try {
+ sessionStorage.removeItem(RELOAD_KEY);
+ } catch {
+ /* ignore */
+ }
+
+ const onError = (event: ErrorEvent) => {
+ const msg = String(event.message || event.error?.message || '');
+ if (!/loading chunk|chunkloaderror|failed to fetch dynamically imported module/i.test(msg)) {
+ return;
+ }
+ try {
+ if (sessionStorage.getItem(RELOAD_KEY)) return;
+ sessionStorage.setItem(RELOAD_KEY, '1');
+ } catch {
+ /* ignore */
+ }
+ window.location.reload();
+ };
+
+ window.addEventListener('error', onError);
+ return () => window.removeEventListener('error', onError);
+ }, []);
+
+ return null;
+}
diff --git a/web/app/globals.css b/web/app/globals.css
index dbfc915..b431661 100644
--- a/web/app/globals.css
+++ b/web/app/globals.css
@@ -551,6 +551,42 @@ code {
font-size: 0.8125rem;
}
+.chat-assistant-card {
+ border-color: var(--app-border-muted);
+ background: rgb(255 255 255 / 0.02);
+}
+
+.chat-assistant-card-partial {
+ border-top: 3px solid rgb(245 158 11 / 0.55);
+}
+
+.chat-prose-nested {
+ font-size: 0.8125rem;
+}
+
+.chat-prose-nested .chat-prose-h1,
+.chat-prose-nested .chat-prose-h2,
+.chat-prose-nested .chat-prose-h3 {
+ margin-top: 0.5rem;
+ font-size: 0.875rem;
+}
+
+.chat-insight-sections .chat-prose {
+ margin-top: 0;
+}
+
+.chat-prose-table-wrap thead th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+}
+
+.chat-prose-pre pre,
+.chat-prose pre {
+ max-height: 16rem;
+ overflow: auto;
+}
+
/* Focus visibility for keyboard users */
.nav-btn:focus-visible,
button:focus-visible,
diff --git a/web/app/layout.tsx b/web/app/layout.tsx
index c726b4b..b9e21e2 100644
--- a/web/app/layout.tsx
+++ b/web/app/layout.tsx
@@ -1,7 +1,7 @@
import { DM_Sans } from 'next/font/google';
-import Script from 'next/script';
import type { ReactNode } from 'react';
import './globals.css';
+import ChunkLoadRecovery from './chunk-load-recovery';
import ClientProviders from './client-providers';
const dmSans = DM_Sans({
@@ -25,10 +25,13 @@ const themeInit = `(function(){try{var v=localStorage.getItem('wp-theme');var d=
export default function RootLayout({ children }: { children: ReactNode }): ReactNode {
return (
+
+
+
-
+
{children}
diff --git a/web/src/components/chat/ChatAssistantMessage.tsx b/web/src/components/chat/ChatAssistantMessage.tsx
new file mode 100644
index 0000000..c2eedea
--- /dev/null
+++ b/web/src/components/chat/ChatAssistantMessage.tsx
@@ -0,0 +1,104 @@
+'use client';
+
+import { Sparkles } from 'lucide-react';
+import ChatBlocks from '@/components/chat/blocks/ChatBlocks';
+import ChatInsightSections from '@/components/chat/ChatInsightSections';
+import ChatToolActivity, { type ToolActivityItem } from '@/components/chat/ChatToolActivity';
+import { preprocessChatMarkdown } from '@/components/chat/preprocessChatMarkdown';
+import { postprocessChatContent } from '@/components/chat/postprocessChatContent';
+import { sanitizeChatProse } from '@/components/chat/sanitizeChatProse';
+import type { ChatBlock } from '@/components/chat/deriveChatBlocks';
+import { strings } from '@/lib/strings';
+
+const c = strings.components.chat;
+
+export interface ChatAssistantMessageProps {
+ content: string;
+ toolActivity?: ToolActivityItem[];
+ blocks?: ChatBlock[];
+ streaming?: boolean;
+ error?: boolean;
+ partialError?: boolean;
+ agentError?: string | null;
+ statusText?: string;
+}
+
+export default function ChatAssistantMessage({
+ content,
+ toolActivity,
+ blocks: blocksOverride,
+ streaming,
+ error,
+ partialError,
+ agentError,
+ statusText,
+}: ChatAssistantMessageProps) {
+ const processed = postprocessChatContent(content, toolActivity, {
+ agentError,
+ partialError,
+ });
+ const blocks = blocksOverride ?? processed.blocks;
+ const prose = processed.prose;
+ const showProse = prose.trim() && !processed.proseHidden;
+ const fatalError = Boolean(error && !partialError && !processed.hasPartialError);
+ const showPartialNote = processed.hasPartialError || partialError;
+
+ const cardClass = fatalError
+ ? 'chat-assistant-card border-red-500/30 bg-red-500/10'
+ : showPartialNote
+ ? 'chat-assistant-card chat-assistant-card-partial'
+ : 'chat-assistant-card';
+
+ const hasBody =
+ blocks.length > 0 ||
+ showProse ||
+ (toolActivity?.length ?? 0) > 0 ||
+ streaming ||
+ statusText;
+
+ if (!hasBody && fatalError) {
+ return (
+
+ {content || c.responseFailed}
+
+ );
+ }
+
+ return (
+
+ {(streaming || (!content && !blocks.length)) && !fatalError ? (
+
+ ) : null}
+
+ {toolActivity?.length ?
: null}
+
+ {blocks.length > 0 ?
: null}
+
+ {processed.proseHidden && blocks.length > 0 ? (
+
{c.proseStrippedNote}
+ ) : null}
+
+ {showPartialNote ? (
+
{c.partialResponseNote}
+ ) : null}
+
+ {showProse ? (
+ streaming && !prose.includes('###') ? (
+
{prose}
+ ) : (
+
+ )
+ ) : content.trim() && !blocks.length ? (
+
+ ) : streaming && statusText ? (
+
{statusText}
+ ) : null}
+
+ );
+}
diff --git a/web/src/components/chat/ChatInsightSections.test.ts b/web/src/components/chat/ChatInsightSections.test.ts
new file mode 100644
index 0000000..9234d60
--- /dev/null
+++ b/web/src/components/chat/ChatInsightSections.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+import { isExpandedSectionTitle, stripEmojiFromTitle } from './chatSectionTitles';
+import { splitInsightSections } from './ChatInsightSections';
+
+describe('chatSectionTitles', () => {
+ it('detects expanded sections', () => {
+ expect(isExpandedSectionTitle('Power Insights')).toBe(true);
+ expect(isExpandedSectionTitle('What the data shows')).toBe(false);
+ });
+
+ it('strips emoji and domain suffix from titles', () => {
+ expect(stripEmojiFromTitle('🔎 Power Insights for codefrydev.in')).toBe('Power Insights');
+ });
+});
+
+describe('splitInsightSections', () => {
+ it('splits markdown into collapsible sections', () => {
+ const sections = splitInsightSections(`### Power Insights
+- One insight
+
+### What the data shows
+- Detail`);
+ expect(sections).toHaveLength(2);
+ expect(sections[0].title).toBe('Power Insights');
+ expect(sections[1].title).toBe('What the data shows');
+ });
+});
diff --git a/web/src/components/chat/ChatInsightSections.tsx b/web/src/components/chat/ChatInsightSections.tsx
new file mode 100644
index 0000000..d4da225
--- /dev/null
+++ b/web/src/components/chat/ChatInsightSections.tsx
@@ -0,0 +1,133 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+import { ChevronDown, ChevronRight, Lightbulb } from 'lucide-react';
+import ChatMarkdown from '@/components/chat/ChatMarkdown';
+import { stripEmojiFromTitle } from '@/components/chat/chatSectionTitles';
+import { format, strings } from '@/lib/strings';
+
+const c = strings.components.chat;
+
+export interface ChatInsightSection {
+ title: string;
+ body: string;
+}
+
+export interface ChatInsightSectionsProps {
+ content: string;
+ streaming?: boolean;
+}
+
+const SECTION_SPLIT_RE = /^#{3,4}\s+(.+)$/gm;
+
+export function splitInsightSections(content: string): ChatInsightSection[] {
+ const trimmed = content.trim();
+ if (!trimmed) return [];
+
+ const matches = [...trimmed.matchAll(SECTION_SPLIT_RE)];
+ if (!matches.length) {
+ return [{ title: c.insightDefaultTitle, body: trimmed }];
+ }
+
+ const sections: ChatInsightSection[] = [];
+ for (let i = 0; i < matches.length; i++) {
+ const match = matches[i];
+ const title = stripEmojiFromTitle(match[1] || c.insightDefaultTitle);
+ const start = (match.index ?? 0) + match[0].length;
+ const end = i + 1 < matches.length ? (matches[i + 1].index ?? trimmed.length) : trimmed.length;
+ const body = trimmed.slice(start, end).trim();
+ if (!body.trim()) continue;
+ sections.push({ title: title || c.insightDefaultTitle, body });
+ }
+ return sections;
+}
+
+function SectionBody({ body, streaming }: { body: string; streaming?: boolean }) {
+ const [showAll, setShowAll] = useState(false);
+ const lines = body.split('\n');
+ const listLines = lines.filter((l) => /^\s*[-*]\s+/.test(l) || /^\s*\d+\.\s+/.test(l));
+ const shouldCollapseList = listLines.length > 5 && !showAll;
+
+ let displayBody = body;
+ if (shouldCollapseList) {
+ let kept = 0;
+ const out: string[] = [];
+ for (const line of lines) {
+ if (/^\s*[-*]\s+/.test(line) || /^\s*\d+\.\s+/.test(line)) {
+ kept += 1;
+ if (kept > 5) continue;
+ }
+ out.push(line);
+ }
+ displayBody = out.join('\n').trim();
+ }
+
+ return (
+
+
+ {shouldCollapseList ? (
+
+ ) : null}
+
+ );
+}
+
+export default function ChatInsightSections({ content, streaming }: ChatInsightSectionsProps) {
+ const sections = useMemo(() => splitInsightSections(content), [content]);
+ const [open, setOpen] = useState>({});
+
+ if (!sections.length) return null;
+
+ const isOpen = (title: string) => {
+ if (title in open) return open[title];
+ return true;
+ };
+
+ const toggle = (title: string) => {
+ setOpen((prev) => ({ ...prev, [title]: !isOpen(title) }));
+ };
+
+ if (sections.length === 1 && !sections[0].title) {
+ return ;
+ }
+
+ return (
+
+ {sections.map((section) => {
+ const expanded = isOpen(section.title);
+ return (
+
+
+ {expanded ? (
+
+
+
+ ) : null}
+
+ );
+ })}
+
+ );
+}
diff --git a/web/src/components/chat/ChatMarkdown.tsx b/web/src/components/chat/ChatMarkdown.tsx
index dff69c7..e39fba6 100644
--- a/web/src/components/chat/ChatMarkdown.tsx
+++ b/web/src/components/chat/ChatMarkdown.tsx
@@ -30,9 +30,10 @@ function insightHeadingClass(children: unknown, base: string): string {
export interface ChatMarkdownProps {
content: string;
streaming?: boolean;
+ nested?: boolean;
}
-export default function ChatMarkdown({ content, streaming }: ChatMarkdownProps) {
+export default function ChatMarkdown({ content, streaming, nested }: ChatMarkdownProps) {
const normalized = useMemo(() => preprocessChatMarkdown(content), [content]);
const components = useMemo(
@@ -102,7 +103,9 @@ export default function ChatMarkdown({ content, streaming }: ChatMarkdownProps)
if (!normalized.trim()) return null;
return (
-
+
{normalized}
diff --git a/web/src/components/chat/ChatMessageList.tsx b/web/src/components/chat/ChatMessageList.tsx
index 37c85b2..e75b53a 100644
--- a/web/src/components/chat/ChatMessageList.tsx
+++ b/web/src/components/chat/ChatMessageList.tsx
@@ -1,24 +1,19 @@
'use client';
import { useEffect, useRef } from 'react';
-import { Sparkles } from 'lucide-react';
-import ChatBlocks from '@/components/chat/blocks/ChatBlocks';
-import ChatMarkdown from '@/components/chat/ChatMarkdown';
-import type { ChatBlock } from '@/components/chat/deriveChatBlocks';
-import { stripRedundantMarkdown } from '@/components/chat/stripRedundantMarkdown';
-import ChatToolActivity, { type ToolActivityItem } from './ChatToolActivity';
-import { strings } from '@/lib/strings';
-
-const c = strings.components.chat;
+import ChatAssistantMessage from '@/components/chat/ChatAssistantMessage';
+import type { ToolActivityItem } from '@/components/chat/ChatToolActivity';
+import { toolEventsToActivity } from '@/components/chat/deriveChatBlocks';
export interface ChatMessage {
id: string | number;
role: 'user' | 'assistant';
content: string;
toolActivity?: ToolActivityItem[];
- blocks?: ChatBlock[];
streaming?: boolean;
error?: boolean;
+ partialError?: boolean;
+ agentError?: string | null;
statusText?: string;
}
@@ -49,52 +44,23 @@ export default function ChatMessageList({ messages, empty }: ChatMessageListProp
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
-
- {msg.role === 'assistant' && (msg.streaming || !msg.content) && !msg.error ? (
-
+ {msg.content}
+
+ ) : (
+
+
- ) : null}
- {msg.toolActivity?.length ? (
-
- ) : null}
- {msg.role === 'assistant' && msg.blocks?.length ? (
-
- ) : null}
- {msg.content ? (
- msg.role === 'user' ? (
-
{msg.content}
- ) : msg.streaming ? (
-
{msg.content}
- ) : (
-
- )
- ) : msg.streaming ? (
-
- {msg.statusText ||
- (msg.toolActivity?.some((t) => t.status === 'running')
- ? c.queryingData
- : c.thinking)}
-
- ) : msg.error && !msg.content ? (
-
{c.responseFailed}
- ) : null}
-
+
+ )}
))}
@@ -102,3 +68,17 @@ export default function ChatMessageList({ messages, empty }: ChatMessageListProp
);
}
+
+export function toolResultToActivity(
+ toolResult: Record | null | undefined,
+): ToolActivityItem[] {
+ return toolEventsToActivity(toolResult);
+}
+
+export function agentErrorFromToolResult(
+ toolResult: Record | null | undefined,
+): string | null {
+ if (!toolResult) return null;
+ const err = toolResult.agent_error;
+ return typeof err === 'string' && err.trim() ? err.trim() : null;
+}
diff --git a/web/src/components/chat/ChatToolActivity.tsx b/web/src/components/chat/ChatToolActivity.tsx
index 5e4de47..9132c0f 100644
--- a/web/src/components/chat/ChatToolActivity.tsx
+++ b/web/src/components/chat/ChatToolActivity.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState } from 'react';
+import { useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Wrench } from 'lucide-react';
import { format, strings } from '@/lib/strings';
@@ -18,13 +18,58 @@ export interface ChatToolActivityProps {
items: ToolActivityItem[];
}
+const WORKFLOW_TOOLS = new Set([
+ 'run_insight_workflow',
+ 'run_technical_workflow',
+ 'run_keyword_workflow',
+ 'run_domain_agent',
+]);
+
+function isFailed(item: ToolActivityItem): boolean {
+ return item.status === 'done' && Boolean(item.result && typeof item.result.error === 'string');
+}
+
+function groupLabel(name: string): string {
+ if (WORKFLOW_TOOLS.has(name)) return c.toolGroupWorkflow;
+ if (name.startsWith('export_')) return c.toolGroupExport;
+ if (name.includes('google') || name.includes('gsc')) return c.toolGroupGsc;
+ if (name.includes('lighthouse') || name.includes('image')) return c.toolGroupPerformance;
+ return c.toolGroupData;
+}
+
export default function ChatToolActivity({ items }: ChatToolActivityProps) {
const [open, setOpen] = useState(false);
+ const failed = useMemo(() => items.filter(isFailed), [items]);
+ const groups = useMemo(() => {
+ const map = new Map();
+ for (const item of items) {
+ const label = groupLabel(item.name);
+ const list = map.get(label) ?? [];
+ list.push(item);
+ map.set(label, list);
+ }
+ return [...map.entries()];
+ }, [items]);
+
if (!items.length) return null;
return (
+ {failed.length > 0 ? (
+
+ {failed.map((item) => (
+
+ {item.name} {c.toolFailedShort}
+
+ ))}
+
+ ) : null}
+
{open ? (
-
);
diff --git a/web/src/components/chat/blocks/ChatBlocks.tsx b/web/src/components/chat/blocks/ChatBlocks.tsx
index 023c893..4f3c759 100644
--- a/web/src/components/chat/blocks/ChatBlocks.tsx
+++ b/web/src/components/chat/blocks/ChatBlocks.tsx
@@ -16,6 +16,7 @@ import ChatImageAuditBlock from './ChatImageAuditBlock';
import ChatImagePagesTableBlock from './ChatImagePagesTableBlock';
import ChatImageAttentionTableBlock from './ChatImageAttentionTableBlock';
import ChatImageLighthouseBlock from './ChatImageLighthouseBlock';
+import ChatToolStatusBlock, { ChatToolTruncatedBlock } from './ChatToolStatusBlock';
export interface ChatBlocksProps {
blocks: ChatBlock[];
@@ -57,6 +58,10 @@ export default function ChatBlocks({ blocks }: ChatBlocksProps) {
return ;
case 'image_lighthouse_list':
return ;
+ case 'tool_status':
+ return ;
+ case 'tool_truncated':
+ return ;
default:
return null;
}
diff --git a/web/src/components/chat/blocks/ChatToolStatusBlock.tsx b/web/src/components/chat/blocks/ChatToolStatusBlock.tsx
new file mode 100644
index 0000000..7a80e5f
--- /dev/null
+++ b/web/src/components/chat/blocks/ChatToolStatusBlock.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+import { AlertCircle, CheckCircle2, Info } from 'lucide-react';
+import { format, strings } from '@/lib/strings';
+import type { ChatBlock } from '@/components/chat/deriveChatBlocks';
+
+const c = strings.components.chat;
+
+type Block = Extract;
+
+export default function ChatToolStatusBlock({ block }: { block: Block }) {
+ const Icon =
+ block.variant === 'empty' ? CheckCircle2 : block.variant === 'error' ? AlertCircle : Info;
+ const tone =
+ block.variant === 'error'
+ ? 'border-red-500/30 bg-red-500/10 text-red-100'
+ : block.variant === 'empty'
+ ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-100'
+ : 'border-amber-500/30 bg-amber-500/10 text-amber-100';
+
+ return (
+
+
+
+
+
{block.toolName}
+
{block.message}
+ {block.hint ?
{block.hint}
: null}
+
+
+
+ );
+}
+
+export function ChatToolTruncatedBlock({
+ block,
+}: {
+ block: Extract;
+}) {
+ return (
+
+ {format(c.truncatedToolNote, {
+ tool: block.toolName,
+ shown: block.shown,
+ total: block.total,
+ })}
+
+ );
+}
diff --git a/web/src/components/chat/chatSectionTitles.ts b/web/src/components/chat/chatSectionTitles.ts
new file mode 100644
index 0000000..eec0c8c
--- /dev/null
+++ b/web/src/components/chat/chatSectionTitles.ts
@@ -0,0 +1,56 @@
+/** Shared section title patterns for preprocess and strip pipelines. */
+
+export const CHAT_SECTION_TITLES = [
+ 'Power Insights',
+ 'Key takeaways',
+ 'Executive summary',
+ 'What the data shows',
+ 'Overall health',
+ 'Site health',
+ 'Critical blockers',
+ 'Top priorities',
+ 'Issues to fix',
+ 'Recommended actions',
+ 'Next steps',
+ 'Quick wins',
+ 'Priority fixes',
+ 'What looks good',
+ "What's working",
+ 'Strengths',
+ 'What needs attention',
+ 'Areas to improve',
+ 'Recommendations',
+ 'Issue details',
+ 'Image audit',
+ 'Lighthouse',
+ 'Search Console',
+ 'GSC summary',
+ 'Compare',
+ 'Security findings',
+] as const;
+
+/** Sections expanded by default in ChatInsightSections. */
+export const CHAT_EXPANDED_SECTIONS = new Set(
+ [
+ 'power insights',
+ 'key takeaways',
+ 'executive summary',
+ 'recommended actions',
+ 'quick wins',
+ 'next steps',
+ 'priority fixes',
+ 'recommendations',
+ ].map((s) => s.toLowerCase()),
+);
+
+export function isExpandedSectionTitle(title: string): boolean {
+ const normalized = title.replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s]+/u, '').trim();
+ return CHAT_EXPANDED_SECTIONS.has(normalized.toLowerCase());
+}
+
+export function stripEmojiFromTitle(title: string): string {
+ return title
+ .replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s🔎📸💡⚠️✅]+/u, '')
+ .replace(/\s+for\s+[\w.-]+\.[a-z]{2,}\s*$/i, '')
+ .trim();
+}
diff --git a/web/src/components/chat/deriveChatBlocks.ts b/web/src/components/chat/deriveChatBlocks.ts
index 27f597f..f2b683b 100644
--- a/web/src/components/chat/deriveChatBlocks.ts
+++ b/web/src/components/chat/deriveChatBlocks.ts
@@ -134,6 +134,19 @@ export type ChatBlock =
}[];
total?: number;
truncated?: boolean;
+ }
+ | {
+ type: 'tool_status';
+ variant: 'error' | 'empty' | 'missing_data';
+ toolName: string;
+ message: string;
+ hint?: string;
+ }
+ | {
+ type: 'tool_truncated';
+ toolName: string;
+ shown: number;
+ total: number;
};
const SUMMARY_TOOLS = new Set(['get_report_summary', 'get_executive_summary']);
@@ -210,6 +223,10 @@ export function blockKey(block: ChatBlock): string {
return `image_attention:${block.title}`;
case 'image_lighthouse_list':
return 'image_lighthouse';
+ case 'tool_status':
+ return `tool_status:${block.toolName}:${block.variant}`;
+ case 'tool_truncated':
+ return `tool_truncated:${block.toolName}`;
default:
return block.type;
}
diff --git a/web/src/components/chat/deriveFallbackBlocks.test.ts b/web/src/components/chat/deriveFallbackBlocks.test.ts
new file mode 100644
index 0000000..64defaa
--- /dev/null
+++ b/web/src/components/chat/deriveFallbackBlocks.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest';
+import { deriveFallbackBlocks } from './deriveFallbackBlocks';
+import type { ToolActivityItem } from './ChatToolActivity';
+
+function doneTool(name: string, result: Record): ToolActivityItem {
+ return { id: name, name, status: 'done', result };
+}
+
+describe('deriveFallbackBlocks', () => {
+ it('creates error block for failed tools', () => {
+ const blocks = deriveFallbackBlocks(
+ [doneTool('get_google_summary', { error: 'GSC not connected' })],
+ [],
+ );
+ expect(blocks).toHaveLength(1);
+ expect(blocks[0]).toMatchObject({
+ type: 'tool_status',
+ variant: 'error',
+ toolName: 'get_google_summary',
+ });
+ });
+
+ it('creates empty block when tool returns no rows and no viz', () => {
+ const blocks = deriveFallbackBlocks(
+ [doneTool('get_critical_issues', { issues: [], total: 0 })],
+ [],
+ );
+ expect(blocks[0]).toMatchObject({ type: 'tool_status', variant: 'empty' });
+ });
+
+ it('creates truncated banner when result is truncated', () => {
+ const blocks = deriveFallbackBlocks(
+ [doneTool('list_issues', { items: [{ url: 'https://a.test' }], total: 50, shown: 10, truncated: true })],
+ [],
+ );
+ expect(blocks.some((b) => b.type === 'tool_truncated')).toBe(true);
+ });
+
+ it('skips workflow parent tools', () => {
+ const blocks = deriveFallbackBlocks(
+ [doneTool('run_technical_workflow', { error: 'failed' })],
+ [],
+ );
+ expect(blocks).toHaveLength(0);
+ });
+});
diff --git a/web/src/components/chat/deriveFallbackBlocks.ts b/web/src/components/chat/deriveFallbackBlocks.ts
new file mode 100644
index 0000000..89fcf25
--- /dev/null
+++ b/web/src/components/chat/deriveFallbackBlocks.ts
@@ -0,0 +1,184 @@
+import type { ToolActivityItem } from '@/components/chat/ChatToolActivity';
+import {
+ blockKey,
+ deriveChatBlocks,
+ type ChatBlock,
+} from '@/components/chat/deriveChatBlocks';
+
+const WORKFLOW_TOOLS = new Set([
+ 'run_insight_workflow',
+ 'run_technical_workflow',
+ 'run_keyword_workflow',
+ 'run_domain_agent',
+]);
+
+const GSC_TOOLS = new Set([
+ 'get_google_summary',
+ 'get_gsc_top_queries',
+ 'get_gsc_top_pages',
+ 'compare_google_metrics',
+]);
+
+const LIGHTHOUSE_TOOLS = new Set(['get_lighthouse_summary', 'list_lighthouse_image_opportunities']);
+
+const ISSUE_LIST_TOOLS = new Set([
+ 'list_issues',
+ 'get_critical_issues',
+ 'list_issues_by_category',
+ 'get_category_issues',
+]);
+
+function asRecord(v: unknown): Record | null {
+ return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null;
+}
+
+function toolProducedVizBlock(toolName: string, vizBlocks: ChatBlock[]): boolean {
+ for (const block of vizBlocks) {
+ if (block.type === 'tool_status' || block.type === 'tool_truncated') continue;
+ if (toolName === 'get_report_summary' && block.type === 'issue_summary') return true;
+ if (ISSUE_LIST_TOOLS.has(toolName) && block.type === 'issue_table') return true;
+ if (GSC_TOOLS.has(toolName) && block.type === 'google_summary') return true;
+ if (LIGHTHOUSE_TOOLS.has(toolName) && block.type === 'lighthouse_scores') return true;
+ if (toolName === 'get_image_audit_summary' && block.type === 'image_audit_summary') return true;
+ if (toolName.startsWith('list_pages_') && block.type === 'image_pages_table') return true;
+ if (toolName.startsWith('export_') && block.type === 'file_download') return true;
+ if (toolName === 'get_category_scores' && block.type === 'category_scores') return true;
+ if (toolName === 'get_issue_priority_breakdown' && block.type === 'label_value_chart') return true;
+ if (toolName === 'get_status_code_breakdown' && block.type === 'status_breakdown') return true;
+ if (toolName === 'get_health_history' && block.type === 'health_trend') return true;
+ if (toolName === 'compare_category_deltas' && block.type === 'compare_category_deltas') return true;
+ }
+ return false;
+}
+
+function hintForError(toolName: string, message: string): string | undefined {
+ const msg = message.toLowerCase();
+ if (msg.includes('no report') || msg.includes('report not found')) {
+ return 'Run an audit for this property first.';
+ }
+ if (msg.includes('gsc') || msg.includes('google') || GSC_TOOLS.has(toolName)) {
+ return 'Connect Google Search Console in Settings.';
+ }
+ if (msg.includes('lighthouse') || LIGHTHOUSE_TOOLS.has(toolName)) {
+ return 'Enable Lighthouse in pipeline settings and re-run the audit.';
+ }
+ if (msg.includes('property')) {
+ return 'Select a property with crawl data.';
+ }
+ return undefined;
+}
+
+function isEmptyResult(result: Record): boolean {
+ if (Array.isArray(result.items) && result.items.length === 0) return true;
+ if (Array.isArray(result.issues) && result.issues.length === 0) return true;
+ if (Array.isArray(result.pages) && result.pages.length === 0) return true;
+ if (Array.isArray(result.queries) && result.queries.length === 0) return true;
+ if (result.total === 0) return true;
+ if (result.count === 0) return true;
+ return false;
+}
+
+function emptyMessageForTool(toolName: string): string {
+ if (ISSUE_LIST_TOOLS.has(toolName)) return 'No matching issues found.';
+ if (GSC_TOOLS.has(toolName)) return 'No Search Console data available for this period.';
+ if (LIGHTHOUSE_TOOLS.has(toolName)) return 'No Lighthouse results for this report.';
+ if (toolName === 'get_image_audit_summary') return 'No image audit data in this report.';
+ return 'No data returned for this query.';
+}
+
+export function deriveFallbackBlocks(
+ toolActivity: ToolActivityItem[],
+ vizBlocks: ChatBlock[],
+): ChatBlock[] {
+ const fallbacks: ChatBlock[] = [];
+ const seen = new Set();
+
+ for (const item of toolActivity) {
+ if (item.status !== 'done' || !item.result) continue;
+ if (WORKFLOW_TOOLS.has(item.name)) continue;
+
+ const result = asRecord(item.result);
+ if (!result) continue;
+
+ if (result.error) {
+ const message = String(result.error);
+ const block: ChatBlock = {
+ type: 'tool_status',
+ variant: 'error',
+ toolName: item.name,
+ message,
+ hint: hintForError(item.name, message),
+ };
+ const key = blockKey(block);
+ if (!seen.has(key)) {
+ seen.add(key);
+ fallbacks.push(block);
+ }
+ continue;
+ }
+
+ if (result.missing) {
+ const block: ChatBlock = {
+ type: 'tool_status',
+ variant: 'missing_data',
+ toolName: item.name,
+ message: String(result.message || result.missing || 'Data not available'),
+ hint: hintForError(item.name, String(result.message || result.missing || '')),
+ };
+ const key = blockKey(block);
+ if (!seen.has(key)) {
+ seen.add(key);
+ fallbacks.push(block);
+ }
+ continue;
+ }
+
+ if (result.truncated === true) {
+ const shown = Number(result.shown ?? result.limit ?? 0);
+ const total = Number(result.total ?? shown);
+ if (total > shown && shown > 0) {
+ const block: ChatBlock = {
+ type: 'tool_truncated',
+ toolName: item.name,
+ shown,
+ total,
+ };
+ const key = blockKey(block);
+ if (!seen.has(key)) {
+ seen.add(key);
+ fallbacks.push(block);
+ }
+ }
+ }
+
+ if (!toolProducedVizBlock(item.name, vizBlocks) && isEmptyResult(result)) {
+ const block: ChatBlock = {
+ type: 'tool_status',
+ variant: 'empty',
+ toolName: item.name,
+ message: emptyMessageForTool(item.name),
+ };
+ const key = blockKey(block);
+ if (!seen.has(key)) {
+ seen.add(key);
+ fallbacks.push(block);
+ }
+ }
+ }
+
+ return fallbacks;
+}
+
+export function mergeChatBlocks(vizBlocks: ChatBlock[], fallbackBlocks: ChatBlock[]): ChatBlock[] {
+ const merged: ChatBlock[] = [];
+ const seen = new Set();
+ for (const block of [...vizBlocks, ...fallbackBlocks]) {
+ const key = blockKey(block);
+ if (seen.has(key)) continue;
+ seen.add(key);
+ merged.push(block);
+ }
+ return merged;
+}
+
+export { deriveChatBlocks };
diff --git a/web/src/components/chat/postprocessChatContent.test.ts b/web/src/components/chat/postprocessChatContent.test.ts
new file mode 100644
index 0000000..75f6f3b
--- /dev/null
+++ b/web/src/components/chat/postprocessChatContent.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from 'vitest';
+import { postprocessChatContent } from './postprocessChatContent';
+import type { ToolActivityItem } from './ChatToolActivity';
+
+function doneTool(name: string, result: Record): ToolActivityItem {
+ return { id: name, name, status: 'done', result };
+}
+
+describe('postprocessChatContent', () => {
+ it('returns blocks and stripped prose for overview duplicate content', () => {
+ const tools = [
+ doneTool('get_report_summary', {
+ health_score: 58,
+ issue_counts: { Critical: 1, High: 2 },
+ total_urls: 20,
+ success_rate: 0.95,
+ site_name: 'codefrydev.in',
+ }),
+ ];
+ const content = `Here's the overview. health score is 58 / 100 with 20 URLs crawled.
+
+### Power Insights
+- Focus on the single critical blocker first.`;
+
+ const out = postprocessChatContent(content, tools);
+ expect(out.blocks.some((b) => b.type === 'issue_summary')).toBe(true);
+ expect(out.prose).toContain('Power Insights');
+ expect(out.prose.toLowerCase()).not.toContain('health score is 58');
+ });
+
+ it('marks prose hidden when strip removes all narrative', () => {
+ const tools = [
+ doneTool('get_report_summary', {
+ health_score: 74,
+ issue_counts: { High: 1 },
+ total_urls: 10,
+ }),
+ ];
+ const content = 'health score is 74 / 100 and 10 URLs crawled with 100% success rate.';
+ const out = postprocessChatContent(content, tools);
+ expect(out.proseHidden).toBe(true);
+ expect(out.prose.trim()).toBe('');
+ });
+
+ it('flags partial error when agent error present with blocks', () => {
+ const tools = [doneTool('list_issues', { issues: [{ priority: 'High', category: 'SEO', url: 'https://a.test', message: 'x' }] })];
+ const out = postprocessChatContent('Partial summary.', tools, {
+ agentError: 'Agent stopped after maximum tool rounds',
+ partialError: true,
+ });
+ expect(out.hasPartialError).toBe(true);
+ expect(out.failedTools).toHaveLength(0);
+ });
+
+ it('falls back to full preprocess when strip removes all prose', () => {
+ const tools = [
+ {
+ id: '1',
+ name: 'get_report_summary',
+ status: 'done' as const,
+ result: {
+ health_score: 58,
+ issue_counts: { Critical: 1 },
+ total_urls: 10,
+ },
+ },
+ ];
+ const content = `| Core Web Vitals | Score 100 – great! |
+| Security | Score 50 – review. |
+
+### Recommended actions
+- Add viewport meta tags on affected pages.`;
+
+ const out = postprocessChatContent(content, tools);
+ expect(out.prose).toContain('Recommended actions');
+ expect(out.prose).toContain('viewport');
+ });
+});
diff --git a/web/src/components/chat/postprocessChatContent.ts b/web/src/components/chat/postprocessChatContent.ts
new file mode 100644
index 0000000..d0fd375
--- /dev/null
+++ b/web/src/components/chat/postprocessChatContent.ts
@@ -0,0 +1,80 @@
+import type { ToolActivityItem } from '@/components/chat/ChatToolActivity';
+import {
+ deriveChatBlocks,
+ deriveFallbackBlocks,
+ mergeChatBlocks,
+} from '@/components/chat/deriveFallbackBlocks';
+import type { ChatBlock } from '@/components/chat/deriveChatBlocks';
+import { preprocessChatMarkdown } from '@/components/chat/preprocessChatMarkdown';
+import { sanitizeChatProse } from '@/components/chat/sanitizeChatProse';
+import { stripRedundantMarkdown } from '@/components/chat/stripRedundantMarkdown';
+
+export interface ToolFailure {
+ name: string;
+ message: string;
+}
+
+export interface PostprocessChatContentOptions {
+ agentError?: string | null;
+ partialError?: boolean;
+}
+
+export interface PostprocessedChatContent {
+ blocks: ChatBlock[];
+ prose: string;
+ proseHidden: boolean;
+ failedTools: ToolFailure[];
+ hasPartialError: boolean;
+}
+
+export function postprocessChatContent(
+ content: string,
+ toolActivity: ToolActivityItem[] | undefined,
+ options: PostprocessChatContentOptions = {},
+): PostprocessedChatContent {
+ const tools = toolActivity ?? [];
+ const vizBlocks = deriveChatBlocks(tools);
+ const fallbackBlocks = deriveFallbackBlocks(tools, vizBlocks);
+ const blocks = mergeChatBlocks(vizBlocks, fallbackBlocks);
+
+ const rawContent = content.trim();
+ const hasCategoryBlocks = blocks.some(
+ (b) =>
+ b.type === 'category_scores' ||
+ b.type === 'issue_summary' ||
+ b.type === 'lighthouse_scores',
+ );
+
+ const stripped = blocks.length ? stripRedundantMarkdown(rawContent, blocks) : rawContent;
+ let prose = sanitizeChatProse(preprocessChatMarkdown(stripped), { hasCategoryBlocks });
+
+ // If aggressive dedup removed everything, keep interpretation prose (headings/bullets).
+ if (
+ !prose.trim() &&
+ rawContent.trim() &&
+ /#{2,3}\s|^\s*[-*]\s+/m.test(rawContent)
+ ) {
+ prose = sanitizeChatProse(preprocessChatMarkdown(rawContent), { hasCategoryBlocks });
+ }
+
+ const proseHidden = Boolean(rawContent && blocks.length > 0 && !prose.trim());
+
+ const failedTools: ToolFailure[] = tools
+ .filter((t) => t.status === 'done' && t.result && typeof t.result.error === 'string')
+ .map((t) => ({
+ name: t.name,
+ message: String(t.result?.error),
+ }));
+
+ const hasPartialError =
+ Boolean(options.partialError) ||
+ Boolean(options.agentError && (blocks.length > 0 || prose.trim()));
+
+ return {
+ blocks,
+ prose,
+ proseHidden,
+ failedTools,
+ hasPartialError,
+ };
+}
diff --git a/web/src/components/chat/preprocessChatMarkdown.test.ts b/web/src/components/chat/preprocessChatMarkdown.test.ts
index 0963884..2b61970 100644
--- a/web/src/components/chat/preprocessChatMarkdown.test.ts
+++ b/web/src/components/chat/preprocessChatMarkdown.test.ts
@@ -15,4 +15,32 @@ describe('preprocessChatMarkdown', () => {
const out = preprocessChatMarkdown(raw);
expect(out).toContain('- Add viewport meta');
});
+
+ it('normalizes emoji power insights title', () => {
+ const raw =
+ '🔎 Power Insights for codefrydev.in\nInsight What the data shows Overall health The score is moderate.';
+ const out = preprocessChatMarkdown(raw);
+ expect(out).toContain('### Power Insights');
+ });
+
+ it('promotes bold section lines to headings', () => {
+ const raw = '**Recommended actions**\n1. Fix canonical tags';
+ const out = preprocessChatMarkdown(raw);
+ expect(out).toContain('### Recommended actions');
+ });
+
+ it('unwraps insight pipe rows to headings instead of tables', () => {
+ const raw = '| Insight | What the data shows |\n| Core Web Vitals | Score 100 – great! |';
+ const out = preprocessChatMarkdown(raw);
+ expect(out).toContain('### What the data shows');
+ expect(out).not.toContain('| Category | Notes |');
+ expect(out).not.toContain('| Insight |');
+ });
+
+ it('does not table-ify single score pipe rows', () => {
+ const raw = '| Security | Score 50 – review findings. |';
+ const out = preprocessChatMarkdown(raw);
+ expect(out).toContain('**Security**');
+ expect(out).not.toMatch(/\| --- \| --- \|/);
+ });
});
diff --git a/web/src/components/chat/preprocessChatMarkdown.ts b/web/src/components/chat/preprocessChatMarkdown.ts
index 594685a..bd4345e 100644
--- a/web/src/components/chat/preprocessChatMarkdown.ts
+++ b/web/src/components/chat/preprocessChatMarkdown.ts
@@ -1,25 +1,116 @@
+import { CHAT_SECTION_TITLES } from '@/components/chat/chatSectionTitles';
+
+const LOOSE_PIPE_ROW_RE = /^\s*\|(.+)\|?\s*$/;
+
+function cellText(raw: string): string {
+ return raw.replace(/^\|+|\|+$/g, '').trim();
+}
+
+function parsePipeRow(line: string): string[] | null {
+ const m = line.trim().match(LOOSE_PIPE_ROW_RE);
+ if (!m) return null;
+ return m[1].split('|').map((c) => c.trim());
+}
+
+function isDashOnly(text: string): boolean {
+ return /^[-–—_\s]+$/.test(text);
+}
+
+function rowHasScore(cells: string[]): boolean {
+ return cells.some((c) => /\bscore\s*\d+/i.test(c) || /\d+\s*\/\s*100/.test(c));
+}
+
+/** Unwrap pipe rows that are not score summaries — headings or bullets instead of tables. */
+function unwrapNonTablePipeRows(content: string): string {
+ const lines = content.split('\n');
+ const out: string[] = [];
+
+ for (const line of lines) {
+ const cells = parsePipeRow(line);
+ if (!cells || cells.length < 2) {
+ out.push(line);
+ continue;
+ }
+
+ if (cells.every((c) => isDashOnly(c))) {
+ continue;
+ }
+
+ if (rowHasScore(cells)) {
+ out.push(line);
+ continue;
+ }
+
+ const [left, ...rest] = cells;
+ const right = rest.join(' — ').trim();
+ const leftNorm = left.toLowerCase();
+
+ if (leftNorm === 'insight' || leftNorm === 'category' || leftNorm === 'notes') {
+ if (right && !isDashOnly(right)) {
+ const matchTitle = CHAT_SECTION_TITLES.find(
+ (t) => right.toLowerCase().startsWith(t.toLowerCase()),
+ );
+ out.push(matchTitle ? `### ${matchTitle}` : `### ${right}`);
+ }
+ continue;
+ }
+
+ if (left && right) {
+ out.push(`- **${left}**: ${right}`);
+ } else if (right) {
+ out.push(`- ${right}`);
+ }
+ }
+
+ return out.join('\n');
+}
+
+/** Only merge consecutive score pipe rows into one GFM table. */
+function normalizeScorePipeTables(content: string): string {
+ const lines = content.split('\n');
+ const out: string[] = [];
+ let i = 0;
+
+ while (i < lines.length) {
+ const cells = parsePipeRow(lines[i]);
+ if (!cells || !rowHasScore(cells)) {
+ out.push(lines[i]);
+ i += 1;
+ continue;
+ }
+
+ const group: string[] = [];
+ while (i < lines.length) {
+ const rowCells = parsePipeRow(lines[i]);
+ if (!rowCells || !rowHasScore(rowCells) || rowCells.every((c) => isDashOnly(c))) break;
+ group.push(lines[i].trim());
+ i += 1;
+ }
+
+ if (group.length >= 2) {
+ out.push('| Category | Notes |', '| --- | --- |', ...group);
+ } else if (group.length === 1) {
+ const [a, ...rest] = parsePipeRow(group[0]) || [];
+ const note = rest.join(' — ');
+ if (a && note) out.push(`- **${a}**: ${note}`);
+ else out.push(group[0]);
+ }
+ }
+
+ return out.join('\n');
+}
+
/** Normalize assistant markdown so section titles and lists render with structure. */
export function preprocessChatMarkdown(content: string): string {
let out = content.trim();
if (!out) return out;
- const sectionHeadings = [
- 'What looks good',
- "What's working",
- 'Strengths',
- 'What needs attention',
- 'Areas to improve',
- 'Top priorities',
- 'Issues to fix',
- 'Recommendations',
- 'Next steps',
- 'Power Insights',
- 'Recommended actions',
- 'Quick wins',
- 'Priority fixes',
- ];
-
- for (const title of sectionHeadings) {
+ out = out.replace(
+ /^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s🔎📸💡]*\s*(Power Insights|Key takeaways|Executive summary)(?:\s+for\s+[\w.-]+)?\s*$/gimu,
+ '### $1',
+ );
+
+ for (const title of CHAT_SECTION_TITLES) {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`([.!?])\\s+(${escaped})\\s+`, 'gi');
out = out.replace(re, `$1\n\n### $2\n\n`);
@@ -27,8 +118,22 @@ export function preprocessChatMarkdown(content: string): string {
out = out.replace(lineStart, `### $1\n\n`);
}
- // Em-dash or hyphen bullets run together in one paragraph → list items
+ out = out.replace(/^\*\*([^*\n]{3,60})\*\*\s*$/gm, '### $1');
+ out = out.replace(/^(Critical blocker \d+|Critical issue\s*[–-]\s*\d+)\s+/gim, '1. $1 ');
out = out.replace(/([.!?])\s+([—–-]\s+)/g, '$1\n$2');
+ out = out.replace(/^\*{3,}\s*$/gm, '---');
+
+ out = unwrapNonTablePipeRows(out);
+ out = normalizeScorePipeTables(out);
+
+ out = out.replace(
+ /^[-*]\s+([A-Z][\w\s]{2,48}(?:Quick Wins|Insights|Actions|Priorities))\s*$/gim,
+ '#### $1\n',
+ );
+ out = out.replace(
+ /^\d+\.\s+([A-Z][\w\s]{2,48}(?:Quick Wins|Insights|Actions|Priorities))\s*$/gim,
+ '#### $1\n',
+ );
const lines = out.split('\n');
const normalized: string[] = [];
@@ -44,3 +149,5 @@ export function preprocessChatMarkdown(content: string): string {
return out.replace(/\n{3,}/g, '\n\n').trim();
}
+
+export { unwrapNonTablePipeRows, normalizeScorePipeTables, parsePipeRow };
diff --git a/web/src/components/chat/sanitizeChatProse.test.ts b/web/src/components/chat/sanitizeChatProse.test.ts
new file mode 100644
index 0000000..de9056e
--- /dev/null
+++ b/web/src/components/chat/sanitizeChatProse.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it } from 'vitest';
+import { preprocessChatMarkdown } from './preprocessChatMarkdown';
+import {
+ sanitizeChatProse,
+ stripLoosePipeScoreRows,
+ stripToolNamesFromProse,
+} from './sanitizeChatProse';
+import { stripRedundantMarkdown } from './stripRedundantMarkdown';
+import type { ChatBlock } from './deriveChatBlocks';
+
+describe('sanitizeChatProse', () => {
+ it('replaces internal tool names with plain language', () => {
+ const raw =
+ '- Run a Technical Workflow (run_technical_workflow) to surface issues.\n- export_audit_report (PDF) for delivery.';
+ const out = stripToolNamesFromProse(raw);
+ expect(out).not.toContain('run_technical_workflow');
+ expect(out).not.toContain('export_audit_report');
+ expect(out).toContain('technical workflow');
+ expect(out).toContain('export audit report');
+ });
+
+ it('removes loose pipe score rows', () => {
+ const raw = `| Core Web Vitals | Score 100 – great! |
+| Security | Score 50 – review findings. |
+Keep fixing viewport tags.`;
+ const out = stripLoosePipeScoreRows(raw);
+ expect(out).not.toContain('Core Web Vitals');
+ expect(out).toContain('viewport');
+ });
+
+ it('strips health score narration inline', () => {
+ const out = sanitizeChatProse(
+ "The site's health score is 58 / 100 – moderate.\n\n### Recommended actions\n1. Fix viewport.",
+ );
+ expect(out.toLowerCase()).not.toContain('health score is 58');
+ expect(out).toContain('Recommended actions');
+ });
+
+ it('removes closing boilerplate', () => {
+ const out = sanitizeChatProse(
+ '### Next steps\n- Fix titles.\n\nLet me know which of these actions you\'d like to run!',
+ );
+ expect(out).not.toMatch(/let me know which/i);
+ });
+
+ it('strips Category Notes markdown tables when category blocks exist', () => {
+ const raw = `| Category | Notes |
+| --- | --- |
+| --- | --- |
+| Core Web Vitals | Score 100 – great! |
+
+### Power Insights
+- Fix viewport.`;
+ const out = sanitizeChatProse(raw, { hasCategoryBlocks: true });
+ expect(out).not.toContain('Core Web Vitals');
+ expect(out).toContain('Power Insights');
+ });
+});
+
+describe('preprocessChatMarkdown pipe rows legacy', () => {
+ it('builds score table only for multiple score rows', () => {
+ const raw = `| Core Web Vitals | Score 100 – great! |
+| Security | Score 50 – review. |`;
+ const out = preprocessChatMarkdown(raw);
+ expect(out).toContain('| Category | Notes |');
+ expect(out).toContain('Core Web Vitals');
+ });
+});
+
+describe('stripRedundantMarkdown pipe scores with blocks', () => {
+ it('drops pipe score lines when category_scores block exists', () => {
+ const blocks: ChatBlock[] = [
+ {
+ type: 'category_scores',
+ categories: [
+ { name: 'Security', score: 50 },
+ { name: 'Content quality', score: 89 },
+ ],
+ },
+ ];
+ const content = `| Security | Score 50 – findings |
+| Content quality | Score 89 – strong |
+
+### Power Insights
+Focus on security headers.`;
+ const out = stripRedundantMarkdown(content, blocks);
+ expect(out).not.toContain('Score 50');
+ expect(out).toContain('Power Insights');
+ });
+});
diff --git a/web/src/components/chat/sanitizeChatProse.ts b/web/src/components/chat/sanitizeChatProse.ts
new file mode 100644
index 0000000..9d3ce0a
--- /dev/null
+++ b/web/src/components/chat/sanitizeChatProse.ts
@@ -0,0 +1,104 @@
+/** Final prose cleanup after markdown preprocess — user-facing language only. */
+
+const TOOL_LABELS: Record = {
+ run_technical_workflow: 'technical workflow',
+ run_insight_workflow: 'insight workflow',
+ run_keyword_workflow: 'keyword workflow',
+ run_domain_agent: 'domain analysis',
+ export_audit_report: 'export audit report',
+ export_compare_csv: 'export comparison CSV',
+ export_list_as_csv: 'export list as CSV',
+ export_custom_report: 'export custom report',
+ get_category_recommendations: 'category recommendations',
+ get_report_summary: 'audit summary',
+ get_critical_issues: 'critical issues list',
+ list_issues: 'issues list',
+ search_audit_tools: 'tool search',
+};
+
+const BOILERPLATE_RE =
+ /^let me know which of these actions you'?d like to run.*$/gim;
+
+const PIPE_SCORE_ROW_RE = /^\s*\|.*\bscore\s*\d+.*\|?\s*$/i;
+
+function humanizeToolName(name: string): string {
+ const key = name.trim().toLowerCase();
+ if (TOOL_LABELS[key]) return TOOL_LABELS[key];
+ return key.replace(/_/g, ' ');
+}
+
+/** Replace snake_case tool references with plain language. */
+export function stripToolNamesFromProse(content: string): string {
+ let out = content;
+
+ for (const [tool, label] of Object.entries(TOOL_LABELS)) {
+ const re = new RegExp(`\\b${tool.replace(/_/g, '_')}\\b`, 'gi');
+ out = out.replace(re, label);
+ }
+
+ // Remaining snake_case tokens that look like tool names (2+ underscores or known prefix)
+ out = out.replace(
+ /\b([a-z]+(?:_[a-z]+)+)\b(?:\s*\([^)]*\))?/gi,
+ (match, name: string) => {
+ if (!/^(get_|list_|run_|export_|compare_)/i.test(name)) return match;
+ return humanizeToolName(name);
+ },
+ );
+
+ return out;
+}
+
+export function stripBoilerplateClosing(content: string): string {
+ return content.replace(BOILERPLATE_RE, '').replace(/\n{3,}/g, '\n\n').trim();
+}
+
+/** Remove pipe-style score rows (often duplicate category cards). */
+export function stripLoosePipeScoreRows(content: string): string {
+ const lines = content.split('\n');
+ const filtered = lines.filter((line) => !PIPE_SCORE_ROW_RE.test(line));
+ return filtered.join('\n').replace(/\n{3,}/g, '\n\n').trim();
+}
+
+/** Remove inline health / category score narration when redundant. */
+export function stripInlineScoreNarration(content: string): string {
+ let out = content;
+ out = out.replace(
+ /\b(?:the site'?s?|overall|current)\s+health score is\s+\d+\s*\/\s*100\b[^.\n]*/gi,
+ '',
+ );
+ out = out.replace(/\bscore\s+\d+\s*\/\s*100\b[^.\n|]*/gi, '');
+ out = out.replace(
+ /\|\s*[^|]*\bhealth score is\s+\d+\s*\/\s*100\b[^|]*\|/gi,
+ '| |',
+ );
+ out = out.replace(/^.*\b\d+\s+(?:high|critical)[‑-]?priority issues?\b.*$/gim, '');
+ return out.replace(/\n{3,}/g, '\n\n').trim();
+}
+
+const CATEGORY_NOTES_TABLE_RE =
+ /(\n|^)\| *Category *\| *Notes *\|\s*\n\|[-:\s|]+\|\s*\n(?:\|[^\n]+\|\s*\n?)+/gi;
+
+/** Remove Category|Notes markdown tables (UI renders category cards instead). */
+export function stripCategoryNotesTables(content: string): string {
+ return content.replace(CATEGORY_NOTES_TABLE_RE, '\n').replace(/\n{3,}/g, '\n\n').trim();
+}
+
+export interface SanitizeChatProseOptions {
+ hasCategoryBlocks?: boolean;
+}
+
+export function sanitizeChatProse(
+ content: string,
+ { hasCategoryBlocks = false }: SanitizeChatProseOptions = {},
+): string {
+ if (!content.trim()) return content;
+ let out = content;
+ if (hasCategoryBlocks) {
+ out = stripCategoryNotesTables(out);
+ }
+ out = stripLoosePipeScoreRows(out);
+ out = stripInlineScoreNarration(out);
+ out = stripToolNamesFromProse(out);
+ out = stripBoilerplateClosing(out);
+ return out.replace(/\n{3,}/g, '\n\n').trim();
+}
diff --git a/web/src/components/chat/stripRedundantMarkdown.test.ts b/web/src/components/chat/stripRedundantMarkdown.test.ts
index 38c0a65..557db41 100644
--- a/web/src/components/chat/stripRedundantMarkdown.test.ts
+++ b/web/src/components/chat/stripRedundantMarkdown.test.ts
@@ -88,4 +88,19 @@ https://example.com/long-path/page-two`;
expect(out).not.toContain('Issue details');
expect(out).not.toContain('https://example.com/long-path');
});
+
+ it('strips lighthouse prose when lighthouse block exists', () => {
+ const content = `### Insights
+Performance score: 42. SEO score: 91.
+Poor performance pages listed below.`;
+ const blocks: ChatBlock[] = [
+ {
+ type: 'lighthouse_scores',
+ scores: { performance: 42, seo: 91 },
+ poorPages: [],
+ },
+ ];
+ const out = stripRedundantMarkdown(content, blocks);
+ expect(out.toLowerCase()).not.toContain('performance score');
+ });
});
diff --git a/web/src/components/chat/stripRedundantMarkdown.ts b/web/src/components/chat/stripRedundantMarkdown.ts
index 5ce91af..7f93822 100644
--- a/web/src/components/chat/stripRedundantMarkdown.ts
+++ b/web/src/components/chat/stripRedundantMarkdown.ts
@@ -1,6 +1,7 @@
import type { ChatBlock } from '@/components/chat/deriveChatBlocks';
const GFM_TABLE_RE = /(\n|^)(\|[^\n]+\|\n\|[-:\s|]+\|\n(?:\|[^\n]+\|\n?)+)/g;
+const JSON_FENCE_RE = /```(?:json)?\s*\n[\s\S]*?```/gi;
function isCategoryScoreTable(headerRow: string): boolean {
const h = headerRow.toLowerCase();
@@ -21,12 +22,17 @@ function isImageMetricTable(headerRow: string): boolean {
}
function shouldStripTable(headerRow: string, blocks: ChatBlock[]): boolean {
+ const h = headerRow.toLowerCase();
+ const isCategoryNotes = /category/.test(h) && /notes|summary|item/.test(h);
for (const block of blocks) {
- if (block.type === 'category_scores' && isCategoryScoreTable(headerRow)) return true;
+ if (block.type === 'category_scores' && (isCategoryScoreTable(headerRow) || isCategoryNotes))
+ return true;
if (block.type === 'issue_table' && isIssueTable(headerRow)) return true;
if (block.type === 'compare_category_deltas' && isCategoryScoreTable(headerRow)) return true;
if (block.type === 'google_summary' && /query|clicks|page/i.test(headerRow)) return true;
if (block.type === 'image_audit_summary' && isImageMetricTable(headerRow)) return true;
+ if (block.type === 'lighthouse_scores' && /performance|lighthouse|category/i.test(headerRow))
+ return true;
}
return false;
}
@@ -41,7 +47,42 @@ function stripTables(content: string, blocks: ChatBlock[]): string {
});
}
-/** Remove prose issue dumps when structured issue blocks already render the data. */
+function stripGlobalMetrics(content: string, blocks: ChatBlock[]): string {
+ const hasSummary = blocks.some(
+ (b) =>
+ b.type === 'issue_summary' ||
+ b.type === 'category_scores' ||
+ b.type === 'status_breakdown' ||
+ b.type === 'lighthouse_scores' ||
+ b.type === 'google_summary',
+ );
+ if (!hasSummary) return content;
+
+ let out = content;
+ out = out.replace(/^.*\bhealth score is\s+\d+\s*\/\s*100\b.*$/gim, '');
+ out = out.replace(/^.*\b\d+\s+URLs?\s+crawl(?:ed)?\b.*$/gim, '');
+ out = out.replace(/^.*\bsuccess rate\b.*$/gim, '');
+ return out.replace(/\n{3,}/g, '\n\n').trim();
+}
+
+function stripUrlLinesWhenTables(content: string, blocks: ChatBlock[]): string {
+ const hasTable = blocks.some(
+ (b) =>
+ b.type === 'issue_table' ||
+ b.type === 'image_pages_table' ||
+ b.type === 'image_attention_table',
+ );
+ if (!hasTable) return content;
+
+ const lines = content.split('\n').filter((line) => {
+ const t = line.trim();
+ if (/^https?:\/\//i.test(t)) return false;
+ if (/^[-*]\s+https?:\/\//i.test(t)) return false;
+ return true;
+ });
+ return lines.join('\n');
+}
+
function stripIssueProse(content: string, blocks: ChatBlock[]): string {
const hasIssueViz = blocks.some(
(b) => b.type === 'issue_table' || b.type === 'issue_summary',
@@ -49,11 +90,8 @@ function stripIssueProse(content: string, blocks: ChatBlock[]): string {
if (!hasIssueViz) return content;
let out = content;
-
- // Drop "## Issue details" sections
out = out.replace(/\n?#{1,3}\s*Issue details[^\n]*\n[\s\S]*?(?=\n#{1,3}\s|$)/gi, '\n');
- // Drop inline "Issue details:" preamble and following enumeration
const detailsIdx = out.search(/\bIssue details:/i);
if (detailsIdx >= 0) {
const before = out.slice(0, detailsIdx).trim();
@@ -69,6 +107,7 @@ function stripIssueProse(content: string, blocks: ChatBlock[]): string {
if (/^https?:\/\//i.test(t)) return false;
if (/affected urls/i.test(t)) return false;
if (/^critical issue \d+/i.test(t)) return false;
+ if (/^critical blocker \d+/i.test(t)) return false;
if (/^\d+\.\s+.+(priority:|affected urls)/i.test(t)) return false;
if (/^priority:\s*(critical|high|medium|low)\s*$/i.test(t)) return false;
return true;
@@ -87,8 +126,6 @@ function stripOverviewProse(content: string, blocks: ChatBlock[]): string {
if (!hasSummary) return content;
let out = content;
-
- // Drop opening audit recap (health score, crawl stats) when viz blocks cover it
out = out.replace(
/^[^\n#]*(?:here'?s|quick read|latest audit|overview)[^\n]*(?:health score|urls?\s+crawl|success rate)[^\n]*\n?/i,
'',
@@ -110,10 +147,8 @@ function stripOverviewProse(content: string, blocks: ChatBlock[]): string {
}
if (blocks.some((b) => b.type === 'category_scores')) {
- out = out.replace(
- /^.*(?:category score|scored?\s+\d+\/100|\/100\s+in\s+\w+).*$/gim,
- '',
- );
+ out = out.replace(/^.*(?:category score|scored?\s+\d+\/100|\/100\s+in\s+\w+).*$/gim, '');
+ out = out.replace(/^.*\|\s*[^|]+\s*\|\s*[^|]*score\s*\d+.*\|.*$/gim, '');
}
if (blocks.some((b) => b.type === 'issue_summary')) {
@@ -124,6 +159,53 @@ function stripOverviewProse(content: string, blocks: ChatBlock[]): string {
return out.replace(/\n{3,}/g, '\n\n').trim();
}
+function stripLighthouseProse(content: string, blocks: ChatBlock[]): string {
+ if (!blocks.some((b) => b.type === 'lighthouse_scores')) return content;
+ let out = content;
+ out = out.replace(/^.*\b(?:performance|accessibility|best practices|seo)\s*(?:score)?:\s*\d+.*$/gim, '');
+ out = out.replace(/^.*\bpoor performance pages?\b.*$/gim, '');
+ return out.replace(/\n{3,}/g, '\n\n').trim();
+}
+
+function stripGoogleProse(content: string, blocks: ChatBlock[]): string {
+ if (!blocks.some((b) => b.type === 'google_summary')) return content;
+ let out = content;
+ out = out.replace(/^.*\b(?:clicks|impressions|ctr|queries|top pages)\b.*\d+.*$/gim, '');
+ return out.replace(/\n{3,}/g, '\n\n').trim();
+}
+
+function stripHealthTrendProse(content: string, blocks: ChatBlock[]): string {
+ if (!blocks.some((b) => b.type === 'health_trend')) return content;
+ return content
+ .replace(/^.*\bhealth (?:score )?(?:trend|history|over time)\b.*$/gim, '')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim();
+}
+
+function stripCompareProse(content: string, blocks: ChatBlock[]): string {
+ if (!blocks.some((b) => b.type === 'compare_category_deltas')) return content;
+ return content
+ .replace(/^.*\b(?:delta|compared to baseline|category change)\b.*$/gim, '')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim();
+}
+
+function stripChartProse(content: string, blocks: ChatBlock[]): string {
+ if (!blocks.some((b) => b.type === 'label_value_chart')) return content;
+ return content
+ .replace(/^.*\b\d+\s*(?:issues?|pages?|urls?)\b.*$/gim, '')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim();
+}
+
+function stripFileDownloadProse(content: string, blocks: ChatBlock[]): string {
+ if (!blocks.some((b) => b.type === 'file_download')) return content;
+ let out = content;
+ out = out.replace(/```[\s\S]*?```/g, '');
+ out = out.replace(/^.*\b(?:base64|file contents|download link)\b.*$/gim, '');
+ return out.replace(/\n{3,}/g, '\n\n').trim();
+}
+
function stripImageAuditProse(content: string, blocks: ChatBlock[]): string {
const hasImageViz = blocks.some(
(b) =>
@@ -141,7 +223,6 @@ function stripImageAuditProse(content: string, blocks: ChatBlock[]): string {
);
out = out.replace(/\n?#{1,3}\s*headline numbers[^\n]*\n[\s\S]*?(?=\n#{1,3}\s|$)/gi, '\n');
- // Drop per-issue URL enumerations when page tables render the same data
if (blocks.some((b) => b.type === 'image_pages_table')) {
out = out.replace(
/\n?#{1,5}\s*\d+\.\s*(?:missing alt|lazy|og|lighthouse|dimensions)[^\n]*\n[\s\S]*?(?=\n#{1,5}\s|\n#{1,3}\s|$)/gi,
@@ -166,12 +247,60 @@ function stripImageAuditProse(content: string, blocks: ChatBlock[]): string {
return out.replace(/\n{3,}/g, '\n\n').trim();
}
+function stripToolJsonFences(content: string, blocks: ChatBlock[]): string {
+ if (!blocks.length) return content;
+ return content.replace(JSON_FENCE_RE, '').replace(/\n{3,}/g, '\n\n').trim();
+}
+
+function stripLoosePipeScoreLines(content: string, blocks: ChatBlock[]): string {
+ const hasCategoryViz = blocks.some(
+ (b) =>
+ b.type === 'category_scores' ||
+ b.type === 'issue_summary' ||
+ b.type === 'lighthouse_scores',
+ );
+ if (!hasCategoryViz) return content;
+
+ const lines = content.split('\n').filter((line) => {
+ const t = line.trim();
+ if (!/^\|/.test(t)) return true;
+ if (/^[\s|:\-–—_]+$/.test(t)) return false;
+ if (/\bscore\s*\d+/i.test(t)) return false;
+ if (/health score/i.test(t)) return false;
+ return true;
+ });
+
+ return lines.join('\n');
+}
+
+function stripPriorityCountLines(content: string, blocks: ChatBlock[]): string {
+ if (!blocks.some((b) => b.type === 'issue_summary' || b.type === 'label_value_chart')) {
+ return content;
+ }
+ return content
+ .replace(/^.*\b\d+\s+(?:high|critical)[‑-]?priority issues?\b.*$/gim, '')
+ .replace(/^.*\b(?:critical|high):\s*\d+\b.*$/gim, '');
+}
+
/** Remove GFM tables and prose duplicated by structured chat blocks. */
export function stripRedundantMarkdown(content: string, blocks: ChatBlock[]): string {
- if (!content.trim() || !blocks.length) return content;
+ if (!content.trim()) return content;
+ if (!blocks.length) return content.trim();
+
let out = stripTables(content, blocks);
out = stripIssueProse(out, blocks);
out = stripOverviewProse(out, blocks);
out = stripImageAuditProse(out, blocks);
+ out = stripLighthouseProse(out, blocks);
+ out = stripGoogleProse(out, blocks);
+ out = stripHealthTrendProse(out, blocks);
+ out = stripCompareProse(out, blocks);
+ out = stripChartProse(out, blocks);
+ out = stripFileDownloadProse(out, blocks);
+ out = stripGlobalMetrics(out, blocks);
+ out = stripLoosePipeScoreLines(out, blocks);
+ out = stripPriorityCountLines(out, blocks);
+ out = stripUrlLinesWhenTables(out, blocks);
+ out = stripToolJsonFences(out, blocks);
return out.replace(/\n{3,}/g, '\n\n').trim();
}
diff --git a/web/src/strings.json b/web/src/strings.json
index 38cd8a5..bfe5229 100644
--- a/web/src/strings.json
+++ b/web/src/strings.json
@@ -3847,6 +3847,18 @@
"unlimitedToolsOnHint": "Up to 100 tool steps per message (default is 10). Click to use standard limit.",
"unlimitedToolsOffHint": "Allow up to 100 tool steps per message for deep multi-step audits.",
"partialToolsSaved": "Tool results were saved from this turn. The assistant did not produce a final summary.",
+ "partialResponseNote": "The assistant stopped before a full summary. Structured results above are still valid.",
+ "proseStrippedNote": "Detailed metrics are shown in the cards above.",
+ "insightDefaultTitle": "Summary",
+ "showAllItems": "Show all {count} items",
+ "truncatedToolNote": "{tool}: showing {shown} of {total} results. Export for the full list.",
+ "toolFailedShort": "failed",
+ "toolGroupWorkflow": "Workflows",
+ "toolGroupExport": "Exports",
+ "toolGroupGsc": "Search Console",
+ "toolGroupPerformance": "Performance & images",
+ "toolGroupData": "Audit data",
+ "dataFromAuditBadge": "From audit data",
"findModel": "Find model",
"changeModel": "Change in AI settings",
"modelSaveFailed": "Could not save model selection",
diff --git a/web/src/views/Chat.tsx b/web/src/views/Chat.tsx
index e156c59..9727c52 100644
--- a/web/src/views/Chat.tsx
+++ b/web/src/views/Chat.tsx
@@ -7,7 +7,10 @@ import { AlertCircle } from 'lucide-react';
import ChatContextBar from '@/components/chat/ChatContextBar';
import ChatShell from '@/components/chat/ChatShell';
import ChatSidebar from '@/components/chat/ChatSidebar';
-import ChatMessageList, { type ChatMessage } from '@/components/chat/ChatMessageList';
+import ChatMessageList, {
+ agentErrorFromToolResult,
+ type ChatMessage,
+} from '@/components/chat/ChatMessageList';
import ChatComposer from '@/components/chat/ChatComposer';
import SuggestedPrompts from '@/components/chat/SuggestedPrompts';
import ChatModelPicker from '@/components/chat/ChatModelPicker';
@@ -19,7 +22,7 @@ import { usePipeline } from '@/context/PipelineContext';
import { apiUrl } from '@/lib/publicBase';
import { format, strings } from '@/lib/strings';
import { consumeChatSse } from '@/components/chat/parseChatSse';
-import { deriveChatBlocks, toolEventsToActivity } from '@/components/chat/deriveChatBlocks';
+import { toolEventsToActivity } from '@/components/chat/deriveChatBlocks';
import type { ToolActivityItem } from '@/components/chat/ChatToolActivity';
import {
buildChatSearchQuery,
@@ -194,13 +197,14 @@ export default function ChatPage() {
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => {
const toolActivity = toolEventsToActivity(m.tool_result);
- const blocks = toolActivity.length ? deriveChatBlocks(toolActivity) : undefined;
+ const agentError = agentErrorFromToolResult(m.tool_result);
return {
id: m.id,
role: m.role as 'user' | 'assistant',
content: m.content,
toolActivity: toolActivity.length ? toolActivity : undefined,
- blocks: blocks?.length ? blocks : undefined,
+ partialError: Boolean(agentError && toolActivity.length > 0),
+ agentError,
};
}),
);
@@ -371,7 +375,6 @@ export default function ChatPage() {
});
patchAssistant({
toolActivity: [...tools],
- blocks: deriveChatBlocks(tools),
streaming: true,
statusText: format(c.toolStatus, { name: evt.name || 'tool' }),
});
@@ -382,34 +385,34 @@ export default function ChatPage() {
}
patchAssistant({
toolActivity: [...tools],
- blocks: deriveChatBlocks(tools),
streaming: true,
});
} else if (evt.type === 'done' && evt.message) {
content = evt.message;
- patchAssistant({ content: evt.message, streaming: true, error: false });
+ patchAssistant({ content: evt.message, streaming: true, error: false, partialError: false });
} else if (evt.type === 'partial_done' && evt.message) {
content = evt.message;
patchAssistant({
content: evt.message,
streaming: true,
error: false,
+ partialError: true,
toolActivity: tools,
- blocks: deriveChatBlocks(tools),
});
} else if (evt.type === 'error') {
streamError = evt.message || c.agentError;
setError(streamError);
+ const hasTools = tools.length > 0;
const fallbackContent =
- content.trim() ||
- (tools.length > 0 ? c.partialToolsSaved : streamError);
+ content.trim() || (hasTools ? c.partialToolsSaved : streamError);
patchAssistant({
content: fallbackContent,
streaming: false,
- error: true,
+ error: !hasTools,
+ partialError: hasTools,
+ agentError: streamError,
statusText: undefined,
toolActivity: tools,
- blocks: deriveChatBlocks(tools),
});
}
});
@@ -430,10 +433,17 @@ export default function ChatPage() {
content: finalContent,
streaming: false,
error: false,
+ partialError: false,
toolActivity: tools,
- blocks: deriveChatBlocks(tools),
});
}
+ } else if (tools.length > 0) {
+ patchAssistant({
+ streaming: false,
+ partialError: true,
+ agentError: streamError,
+ toolActivity: tools,
+ });
}
if (sid) await loadMessages(sid);
if (propertyId) await loadSessions(propertyId);
From bd3f4a4c4d57a2d3b118a431b2a9fa4660dd51bb Mon Sep 17 00:00:00 2001
From: PrashantUnity
Date: Wed, 17 Jun 2026 02:42:10 +0530
Subject: [PATCH 09/11] better answer get in predicted format than make it
weird also strip
---
src/website_profiling/llm/agent.py | 107 +++++++-----
src/website_profiling/llm/chat_narrative.py | 163 ++++++++++++++++++
src/website_profiling/llm/prompts.py | 12 ++
tests/test_chat_agent.py | 153 +++++++++-------
tests/test_chat_narrative.py | 97 +++++++++++
web/app/api/chat/route.ts | 42 ++++-
.../components/chat/ChatAssistantMessage.tsx | 35 +++-
web/src/components/chat/ChatMessageList.tsx | 7 +
.../components/chat/ChatNarrativeSections.tsx | 118 +++++++++++++
web/src/components/chat/parseChatSse.test.ts | 11 ++
web/src/components/chat/parseChatSse.ts | 13 ++
web/src/strings.json | 3 +
web/src/types/chatNarrative.test.ts | 26 +++
web/src/types/chatNarrative.ts | 25 +++
web/src/views/Chat.tsx | 33 +++-
15 files changed, 721 insertions(+), 124 deletions(-)
create mode 100644 src/website_profiling/llm/chat_narrative.py
create mode 100644 tests/test_chat_narrative.py
create mode 100644 web/src/components/chat/ChatNarrativeSections.tsx
create mode 100644 web/src/types/chatNarrative.test.ts
create mode 100644 web/src/types/chatNarrative.ts
diff --git a/src/website_profiling/llm/agent.py b/src/website_profiling/llm/agent.py
index d82b78d..e9b0a1b 100644
--- a/src/website_profiling/llm/agent.py
+++ b/src/website_profiling/llm/agent.py
@@ -22,6 +22,7 @@
select_tools_for_turn,
)
from .base import ChatResult, ToolCall, get_llm_client
+from .chat_narrative import ChatNarrativeError, synthesize_chat_narrative
MAX_TOOL_ROUNDS_DEFAULT = 10
MAX_TOOL_ROUNDS_EXTENDED = 100
@@ -51,6 +52,8 @@ def _max_tool_rounds(cfg: dict[str, str]) -> int:
pass
return MAX_TOOL_ROUNDS_DEFAULT
+NARRATIVE_FAILED_MSG = "Could not generate a summary. Tool results are shown below."
+
SYSTEM_PROMPT = """You are Site Audit AI, a technical SEO assistant for a self-hosted site audit platform.
You help users understand crawl results, audit issues, Lighthouse scores, keywords, and Search Console data.
@@ -63,7 +66,7 @@ def _max_tool_rounds(cfg: dict[str, str]) -> int:
- Use get_data_coverage_report when tools return empty or missing data.
Image playbook:
-- Overview: get_image_audit_summary first — the UI renders summary cards, page preview lists (alt/lazy/OG/dimensions), and Lighthouse image findings. Write only ### Power Insights and ### Recommended actions (interpretation). Never repeat counts, URL lists, or markdown tables of pages.
+- Overview: get_image_audit_summary first — the UI renders summary cards, page preview lists (alt/lazy/OG/dimensions), and Lighthouse image findings. Call tools only; the app generates user-facing narrative separately.
- Missing alt / lazy / OG / dimensions: get_image_audit_summary includes previews; call list_pages_* only if the user wants the full exportable list
- All image URLs: list_site_image_urls (optional kind filter)
- Lighthouse image issues: list_lighthouse_image_opportunities
@@ -95,22 +98,14 @@ def _max_tool_rounds(cfg: dict[str, str]) -> int:
- When citing issues, include the URL when available.
- The chat UI automatically renders charts, gauges, and tables from tool results. Never tell the user you cannot show graphs or charts, and never send them to other app pages for data you can fetch with tools.
- For visual or chart requests, always call the appropriate tools first, then give a short interpretation (2–4 sentences) with recommendations.
-- When tools return issue lists, scores, or breakdowns, keep the narrative short. Do not re-list every issue or duplicate data in markdown tables—the UI renders structured blocks from tool data.
-- Use markdown headings and bullets for structure. Do not emit fake chart JSON or custom visualization blocks.
-- After tool calls that return summary data, respond using this template only (no emojis in headings, max 5 bullets/items per section, total prose under ~150 words):
-
-### Power Insights
-- (interpretation bullets)
-
-### Recommended actions
-1. (numbered actions)
-
-- Do not repeat health scores, URL counts, success rates, category scores, priority counts, or URL lists in prose when the UI already shows them in cards or tables.
-- Never mention internal tool names (e.g. run_technical_workflow, export_audit_report) in user-facing text — describe actions in plain language ("run a technical workflow", "export the audit as PDF").
-- Do not use pipe-table rows for category scores; the UI renders category score cards automatically.
+- When tools return issue lists, scores, or breakdowns, do not re-list them in prose—the UI renders structured blocks from tool data.
+- Do not emit markdown headings, bullet lists, or pipe tables for the user. The app synthesizes the final narrative from tool results.
+- After gathering enough data via tools, stop calling tools. A brief internal acknowledgment is enough; user-facing text is generated separately.
+- Do not repeat health scores, URL counts, success rates, category scores, priority counts, or URL lists when the UI already shows them in cards or tables.
+- Never mention internal tool names (e.g. run_technical_workflow, export_audit_report) in user-facing text.
- You are read-only: you cannot run crawls or change settings.
- Do not pass property_id or report_id in tool calls — they are injected from the active chat property.
-- If data is missing, say what integration or crawl step is needed.
+- If data is missing, say what integration or crawl step is needed (briefly; narrative will be expanded separately).
"""
REACT_PROMPT_SUFFIX = """
@@ -155,8 +150,6 @@ def _react_step(
tool_calls=[ToolCall(id="react-0", name=name, arguments=args)],
)
text = str(data.get("text") or data.get("answer") or data.get("content") or "")
- if on_token and text:
- on_token(text)
return ChatResult(content=text)
@@ -217,6 +210,41 @@ def _build_openai_messages(history: list[dict[str, str]]) -> list[dict[str, Any]
return out
+def _finish_with_narrative(
+ cfg: dict[str, str],
+ user_message: str,
+ tool_events: list[dict[str, Any]],
+ on_event: Callable[[dict], None] | None,
+ *,
+ partial_note: str | None = None,
+) -> dict[str, Any]:
+ if partial_note:
+ _emit(on_event, {"type": "partial_done", "message": partial_note})
+
+ def on_status(phase: str) -> None:
+ detail = "Retrying summary…" if phase == "retrying" else "Summarizing insights…"
+ _emit(on_event, {"type": "status", "phase": "synthesizing", "detail": detail})
+
+ try:
+ narrative = synthesize_chat_narrative(
+ cfg,
+ user_message,
+ tool_events,
+ on_status=on_status,
+ )
+ except ChatNarrativeError:
+ _emit(on_event, {"type": "error", "message": NARRATIVE_FAILED_MSG})
+ return {
+ "ok": False,
+ "error": NARRATIVE_FAILED_MSG,
+ "tool_events": tool_events,
+ }
+
+ _emit(on_event, {"type": "narrative", "narrative": narrative})
+ _emit(on_event, {"type": "done"})
+ return {"ok": True, "tool_events": tool_events, "narrative": narrative}
+
+
def run_agent_turn(
messages: list[dict[str, str]],
context: AuditToolContext,
@@ -225,7 +253,7 @@ def run_agent_turn(
) -> dict[str, Any]:
"""
Run the agent loop. Emits NDJSON-style events via on_event.
- Returns final result dict with ok, message, tool_events.
+ Returns final result dict with ok, tool_events, and narrative on success.
"""
cfg = load_llm_config_from_db()
if not llm_is_enabled(cfg):
@@ -245,11 +273,8 @@ def run_agent_turn(
active_names = select_tools_for_turn(last_user, messages)
tools = openai_tools_schema(active_names, context_scoped=True)
tool_events: list[dict[str, Any]] = []
- final_message = ""
max_rounds = _max_tool_rounds(cfg)
-
- def on_token(text: str) -> None:
- _emit(on_event, {"type": "token", "text": strip_surrogates(text)})
+ partial_note: str | None = None
for _round in range(max_rounds):
_emit(on_event, {
@@ -260,13 +285,13 @@ def on_token(text: str) -> None:
try:
llm_messages = sanitize_unicode_deep(openai_messages)
if _supports_native_tools(client):
- result = client.chat_with_tools(llm_messages, tools, on_token=on_token)
+ result = client.chat_with_tools(llm_messages, tools, on_token=None)
else:
result = _react_step(
client,
llm_messages,
_tools_description(names=active_names, compact=True),
- on_token,
+ None,
)
except Exception as e:
msg = str(e).strip() or type(e).__name__
@@ -368,26 +393,18 @@ def _run_tool(tc: ToolCall) -> dict[str, Any]:
tools = openai_tools_schema(active_names, context_scoped=True)
continue
- final_message = strip_surrogates(result.content).strip()
- if final_message:
- _emit(on_event, {"type": "done", "message": final_message})
- return {"ok": True, "message": final_message, "tool_events": tool_events}
-
break
+ else:
+ if tool_events:
+ partial_note = (
+ f"The agent completed {len(tool_events)} tool step(s) but did not finish "
+ "all planned steps. Tool results are preserved below."
+ )
- err = "Agent stopped after maximum tool rounds without a final answer."
- partial = final_message
- if not partial and tool_events:
- partial = (
- f"The agent completed {len(tool_events)} tool step(s) but did not finish "
- "with a final summary. Tool results are preserved below."
- )
- if partial:
- _emit(on_event, {"type": "partial_done", "message": partial})
- _emit(on_event, {"type": "error", "message": err})
- return {
- "ok": False,
- "error": err,
- "message": partial,
- "tool_events": tool_events,
- }
+ return _finish_with_narrative(
+ cfg,
+ last_user,
+ tool_events,
+ on_event,
+ partial_note=partial_note,
+ )
diff --git a/src/website_profiling/llm/chat_narrative.py b/src/website_profiling/llm/chat_narrative.py
new file mode 100644
index 0000000..f813fa5
--- /dev/null
+++ b/src/website_profiling/llm/chat_narrative.py
@@ -0,0 +1,163 @@
+"""Structured narrative synthesis for chat turns."""
+from __future__ import annotations
+
+import json
+from typing import Any, Callable
+
+from .base import get_llm_client, parse_json_response
+from .prompts import CHAT_NARRATIVE_REPAIR_SYSTEM, CHAT_NARRATIVE_SYSTEM
+
+MAX_ITEMS = 5
+MAX_PAYLOAD_CHARS = 10000
+MAX_PREVIOUS_RESPONSE_CHARS = 4000
+
+NarrativeStatusCallback = Callable[[str], None]
+
+
+class ChatNarrativeError(Exception):
+ def __init__(self, message: str, errors: list[str] | None = None) -> None:
+ super().__init__(message)
+ self.errors = errors or []
+
+
+def build_synthesis_payload(
+ user_message: str,
+ tool_events: list[dict[str, Any]],
+ *,
+ conversation_snippet: str | None = None,
+) -> str:
+ compact_events = [
+ {
+ "name": ev.get("name"),
+ "args": ev.get("args"),
+ "result": ev.get("result"),
+ }
+ for ev in tool_events
+ ]
+ payload: dict[str, Any] = {
+ "user_question": user_message,
+ "tool_results": compact_events,
+ }
+ if conversation_snippet:
+ payload["conversation_context"] = conversation_snippet
+ raw = json.dumps(payload, indent=2, default=str)
+ if len(raw) > MAX_PAYLOAD_CHARS:
+ return raw[:MAX_PAYLOAD_CHARS] + "\n…(truncated)"
+ return raw
+
+
+def _normalize_string_list(value: Any, field: str, errors: list[str]) -> list[str]:
+ if value is None:
+ errors.append(f"missing key {field}")
+ return []
+ if not isinstance(value, list):
+ errors.append(f"{field} must be an array")
+ return []
+ if len(value) > MAX_ITEMS:
+ errors.append(f"{field} has more than {MAX_ITEMS} items")
+ out: list[str] = []
+ for i, item in enumerate(value):
+ if not isinstance(item, str):
+ errors.append(f"{field}[{i}] must be a string")
+ continue
+ text = item.strip()
+ if not text:
+ errors.append(f"{field}[{i}] is empty")
+ continue
+ out.append(text)
+ if len(out) >= MAX_ITEMS:
+ break
+ return out
+
+
+def validate_chat_narrative(raw: dict[str, Any]) -> tuple[dict[str, list[str]], list[str]]:
+ errors: list[str] = []
+ if not isinstance(raw, dict):
+ return {"power_insights": [], "recommended_actions": []}, ["response must be a JSON object"]
+
+ insights = _normalize_string_list(raw.get("power_insights"), "power_insights", errors)
+ actions = _normalize_string_list(raw.get("recommended_actions"), "recommended_actions", errors)
+
+ if not insights and not actions:
+ errors.append("both power_insights and recommended_actions are empty after normalization")
+
+ return {"power_insights": insights, "recommended_actions": actions}, errors
+
+
+def _coerce_attempt(raw: Any) -> tuple[dict[str, Any], str]:
+ if isinstance(raw, dict):
+ return raw, json.dumps(raw, default=str)
+ text = str(raw or "").strip()
+ return parse_json_response(text), text
+
+
+def _attempt_synthesis(
+ client: Any,
+ system: str,
+ user: str,
+) -> tuple[dict[str, list[str]] | None, list[str], str]:
+ errors: list[str] = []
+ raw_text = ""
+ try:
+ raw = client.complete_json(system, user)
+ parsed, raw_text = _coerce_attempt(raw)
+ narrative, errors = validate_chat_narrative(parsed)
+ if not errors:
+ return narrative, [], raw_text
+ except Exception as e: # noqa: BLE001 - convert to validation errors for repair pass
+ errors = [str(e).strip() or type(e).__name__]
+ if not raw_text:
+ raw_text = errors[0]
+ return None, errors, raw_text
+
+
+def synthesize_chat_narrative(
+ cfg: dict[str, str],
+ user_message: str,
+ tool_events: list[dict[str, Any]],
+ *,
+ on_status: NarrativeStatusCallback | None = None,
+) -> dict[str, list[str]]:
+ """Synthesize narrative JSON; retries once with repair prompt before raising."""
+ client = get_llm_client(cfg)
+ payload = build_synthesis_payload(user_message, tool_events)
+
+ if on_status:
+ on_status("synthesizing")
+
+ narrative, errors, previous = _attempt_synthesis(client, CHAT_NARRATIVE_SYSTEM, payload)
+ if narrative is not None:
+ return narrative
+
+ if on_status:
+ on_status("retrying")
+
+ try:
+ original_data = json.loads(payload)
+ except json.JSONDecodeError:
+ original_data = payload
+
+ repair_payload = json.dumps(
+ {
+ "original_data": original_data,
+ "previous_response": (previous or "")[:MAX_PREVIOUS_RESPONSE_CHARS],
+ "errors": errors,
+ "required_schema": {
+ "power_insights": ["string"],
+ "recommended_actions": ["string"],
+ },
+ },
+ indent=2,
+ default=str,
+ )
+
+ narrative2, errors2, _ = _attempt_synthesis(
+ client, CHAT_NARRATIVE_REPAIR_SYSTEM, repair_payload,
+ )
+ if narrative2 is not None:
+ return narrative2
+
+ raise ChatNarrativeError(
+ "Chat narrative synthesis failed after repair attempt.",
+ errors=errors + errors2,
+ )
diff --git a/src/website_profiling/llm/prompts.py b/src/website_profiling/llm/prompts.py
index 55dec85..6408d45 100644
--- a/src/website_profiling/llm/prompts.py
+++ b/src/website_profiling/llm/prompts.py
@@ -87,3 +87,15 @@
AUDIT_EXECUTIVE_SYSTEM = """You write a short executive summary for a site audit report for agency clients.
Use ONLY the scores and issues provided. Be direct and prioritize by traffic impact.
Return JSON: {"summary": "3-5 sentences in plain language", "priorities": ["bullet 1", "bullet 2", "bullet 3"]}"""
+
+CHAT_NARRATIVE_SYSTEM = """You write the user-facing narrative for a site-audit chat turn.
+Use ONLY the user question and tool results provided. Do not invent metrics, URLs, or scores.
+The chat UI already renders charts, tables, and score cards from tool data — do not repeat those numbers.
+Return JSON only: {"power_insights": ["..."], "recommended_actions": ["..."]}
+Max 5 items per array. Plain language. No internal tool names. No emoji."""
+
+CHAT_NARRATIVE_REPAIR_SYSTEM = """Your previous response was not valid JSON matching the required schema.
+Return ONLY a JSON object with exactly these keys:
+{"power_insights": ["string", ...], "recommended_actions": ["string", ...]}
+Each value must be a non-empty array of non-empty strings (max 5 each).
+Use ONLY the original user question and tool data provided. Do not invent metrics."""
diff --git a/tests/test_chat_agent.py b/tests/test_chat_agent.py
index e50fa34..e9d27c0 100644
--- a/tests/test_chat_agent.py
+++ b/tests/test_chat_agent.py
@@ -1,17 +1,23 @@
"""Tests for chat agent loop."""
from __future__ import annotations
-from unittest.mock import MagicMock, patch
+from unittest.mock import patch
from website_profiling.llm.agent import (
MAX_TOOL_ROUNDS,
MAX_TOOL_ROUNDS_EXTENDED,
+ NARRATIVE_FAILED_MSG,
_max_tool_rounds,
run_agent_turn,
)
from website_profiling.llm.base import ChatResult, ToolCall
from website_profiling.tools.audit_tools import AuditToolContext
+VALID_NARRATIVE = {
+ "power_insights": ["Crawl health is solid overall."],
+ "recommended_actions": ["Address critical issues first."],
+}
+
class FakeToolClient:
def __init__(self, steps: list[ChatResult]) -> None:
@@ -21,15 +27,16 @@ def __init__(self, steps: list[ChatResult]) -> None:
def chat_with_tools(self, messages, tools, *, on_token=None):
result = self._steps[min(self._calls, len(self._steps) - 1)]
self._calls += 1
- if on_token and result.content:
- on_token(result.content)
return result
+ def complete_json(self, system, user):
+ return VALID_NARRATIVE
+
def test_agent_tool_then_answer() -> None:
client = FakeToolClient([
ChatResult(tool_calls=[ToolCall(id="tc1", name="list_issues", arguments={"limit": 5})]),
- ChatResult(content="Found 3 critical issues."),
+ ChatResult(content="ignored internal stop"),
])
events: list[dict] = []
ctx = AuditToolContext(property_id=1)
@@ -38,23 +45,26 @@ def test_agent_tool_then_answer() -> None:
"llm_enabled": True, "llm_provider": "openai", "llm_api_key": "sk-test",
}):
with patch("website_profiling.llm.agent.get_llm_client", return_value=client):
- with patch(
- "website_profiling.llm.agent.dispatch_tool",
- return_value={"issues": [], "total": 0},
- ) as mock_dispatch:
- result = run_agent_turn(
- [{"role": "user", "content": "What are the top issues?"}],
- ctx,
- on_event=events.append,
- )
+ with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client):
+ with patch(
+ "website_profiling.llm.agent.dispatch_tool",
+ return_value={"issues": [], "total": 0},
+ ) as mock_dispatch:
+ result = run_agent_turn(
+ [{"role": "user", "content": "What are the top issues?"}],
+ ctx,
+ on_event=events.append,
+ )
assert result["ok"] is True
- assert "critical" in result["message"].lower()
+ assert result["narrative"] == VALID_NARRATIVE
mock_dispatch.assert_called_once()
types = [e["type"] for e in events]
assert "tool_start" in types
assert "tool_end" in types
+ assert "narrative" in types
assert "done" in types
+ assert "token" not in types
def test_agent_runs_multiple_tool_calls_in_one_turn() -> None:
@@ -65,7 +75,7 @@ def test_agent_runs_multiple_tool_calls_in_one_turn() -> None:
ToolCall(id="b", name="get_critical_issues", arguments={"limit": 5}),
ToolCall(id="c", name="get_issue_priority_breakdown", arguments={}),
]),
- ChatResult(content="Here is your overview."),
+ ChatResult(content="stop"),
])
events: list[dict] = []
ctx = AuditToolContext(property_id=1, report_id=1)
@@ -79,39 +89,31 @@ def fake_dispatch(name, args, *, context=None):
"llm_enabled": True, "llm_provider": "openai", "llm_api_key": "sk-test",
}):
with patch("website_profiling.llm.agent.get_llm_client", return_value=client):
- with patch("website_profiling.llm.agent.chat_tool_mode", return_value="full"):
- with patch(
- "website_profiling.llm.agent.dispatch_tool",
- side_effect=fake_dispatch,
- ):
- result = run_agent_turn(
- [{"role": "user", "content": "give me a full audit overview"}],
- ctx,
- on_event=events.append,
- )
+ with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client):
+ with patch("website_profiling.llm.agent.chat_tool_mode", return_value="full"):
+ with patch(
+ "website_profiling.llm.agent.dispatch_tool",
+ side_effect=fake_dispatch,
+ ):
+ result = run_agent_turn(
+ [{"role": "user", "content": "give me a full audit overview"}],
+ ctx,
+ on_event=events.append,
+ )
assert result["ok"] is True
- # every tool was dispatched...
assert sorted(dispatched) == [
"get_critical_issues", "get_issue_priority_breakdown", "get_report_summary",
]
- # ...and results were applied back in request order
assert [e["name"] for e in result["tool_events"]] == [
"get_report_summary", "get_critical_issues", "get_issue_priority_breakdown",
]
- starts = [e["name"] for e in events if e["type"] == "tool_start"]
- ends = [e["name"] for e in events if e["type"] == "tool_end"]
- assert starts == [
- "get_report_summary", "get_critical_issues", "get_issue_priority_breakdown",
- ]
- assert sorted(ends) == sorted(starts)
def test_agent_isolates_tool_exception() -> None:
- """A handler raising mid-turn becomes an error result instead of crashing the turn."""
client = FakeToolClient([
ChatResult(tool_calls=[ToolCall(id="x", name="list_issues", arguments={})]),
- ChatResult(content="Recovered."),
+ ChatResult(content="stop"),
])
ctx = AuditToolContext(property_id=1)
@@ -119,15 +121,16 @@ def test_agent_isolates_tool_exception() -> None:
"llm_enabled": True, "llm_provider": "openai", "llm_api_key": "sk-test",
}):
with patch("website_profiling.llm.agent.get_llm_client", return_value=client):
- with patch("website_profiling.llm.agent.chat_tool_mode", return_value="full"):
- with patch(
- "website_profiling.llm.agent.dispatch_tool",
- side_effect=RuntimeError("db exploded"),
- ):
- result = run_agent_turn(
- [{"role": "user", "content": "list issues"}],
- ctx,
- )
+ with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client):
+ with patch("website_profiling.llm.agent.chat_tool_mode", return_value="full"):
+ with patch(
+ "website_profiling.llm.agent.dispatch_tool",
+ side_effect=RuntimeError("db exploded"),
+ ):
+ result = run_agent_turn(
+ [{"role": "user", "content": "list issues"}],
+ ctx,
+ )
assert result["ok"] is True
assert result["tool_events"][0]["result"]["error"] == "db exploded"
@@ -147,7 +150,7 @@ def test_agent_disabled_llm() -> None:
assert events[-1]["type"] == "error"
-def test_max_tool_rounds() -> None:
+def test_max_tool_rounds_still_synthesizes() -> None:
always_tool = ChatResult(
tool_calls=[ToolCall(id="x", name="list_properties", arguments={})],
)
@@ -158,23 +161,57 @@ def test_max_tool_rounds() -> None:
with patch("website_profiling.llm.agent.load_llm_config_from_db", return_value={
"llm_enabled": True, "llm_provider": "openai", "llm_api_key": "sk-test",
"llm_chat_unlimited_tool_rounds": "false",
+ }):
+ with patch("website_profiling.llm.agent.get_llm_client", return_value=client):
+ with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client):
+ with patch(
+ "website_profiling.llm.agent.dispatch_tool",
+ return_value={"properties": []},
+ ):
+ result = run_agent_turn(
+ [{"role": "user", "content": "List properties"}],
+ ctx,
+ on_event=events.append,
+ )
+
+ assert result["ok"] is True
+ assert result["narrative"] == VALID_NARRATIVE
+ assert any(e["type"] == "partial_done" for e in events)
+ assert any(e["type"] == "narrative" for e in events)
+
+
+def test_narrative_failure_emits_error_and_preserves_tools() -> None:
+ from website_profiling.llm.chat_narrative import ChatNarrativeError
+
+ client = FakeToolClient([
+ ChatResult(tool_calls=[ToolCall(id="tc1", name="list_issues", arguments={})]),
+ ChatResult(content="stop"),
+ ])
+ events: list[dict] = []
+ ctx = AuditToolContext(property_id=1)
+
+ with patch("website_profiling.llm.agent.load_llm_config_from_db", return_value={
+ "llm_enabled": True, "llm_provider": "openai", "llm_api_key": "sk-test",
}):
with patch("website_profiling.llm.agent.get_llm_client", return_value=client):
with patch(
"website_profiling.llm.agent.dispatch_tool",
- return_value={"properties": []},
+ return_value={"issues": [{"url": "/a"}]},
):
- result = run_agent_turn(
- [{"role": "user", "content": "List properties"}],
- ctx,
- on_event=events.append,
- )
+ with patch(
+ "website_profiling.llm.agent.synthesize_chat_narrative",
+ side_effect=ChatNarrativeError("failed"),
+ ):
+ result = run_agent_turn(
+ [{"role": "user", "content": "issues"}],
+ ctx,
+ on_event=events.append,
+ )
assert result["ok"] is False
- assert "maximum tool rounds" in result["error"].lower()
- assert result.get("message")
+ assert result["error"] == NARRATIVE_FAILED_MSG
+ assert len(result["tool_events"]) == 1
assert events[-1]["type"] == "error"
- assert any(e["type"] == "partial_done" for e in events)
def test_max_tool_rounds_extended_when_unlimited_enabled() -> None:
@@ -182,9 +219,9 @@ def test_max_tool_rounds_extended_when_unlimited_enabled() -> None:
assert _max_tool_rounds({"llm_chat_unlimited_tool_rounds": "false"}) == MAX_TOOL_ROUNDS
-def test_system_prompt_has_output_template() -> None:
+def test_system_prompt_does_not_require_markdown_template() -> None:
from website_profiling.llm.agent import SYSTEM_PROMPT
- assert "### Power Insights" in SYSTEM_PROMPT
- assert "### Recommended actions" in SYSTEM_PROMPT
- assert "no emojis in headings" in SYSTEM_PROMPT.lower()
+ assert "### Power Insights" not in SYSTEM_PROMPT
+ assert "### Recommended actions" not in SYSTEM_PROMPT
+ assert "generated separately" in SYSTEM_PROMPT.lower()
diff --git a/tests/test_chat_narrative.py b/tests/test_chat_narrative.py
new file mode 100644
index 0000000..5b76414
--- /dev/null
+++ b/tests/test_chat_narrative.py
@@ -0,0 +1,97 @@
+"""Tests for structured chat narrative synthesis."""
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from website_profiling.llm.chat_narrative import (
+ ChatNarrativeError,
+ build_synthesis_payload,
+ synthesize_chat_narrative,
+ validate_chat_narrative,
+)
+
+
+def test_validate_chat_narrative_accepts_valid_payload() -> None:
+ narrative, errors = validate_chat_narrative({
+ "power_insights": [" Strong crawl health "],
+ "recommended_actions": ["Fix broken links"],
+ })
+ assert not errors
+ assert narrative["power_insights"] == ["Strong crawl health"]
+ assert narrative["recommended_actions"] == ["Fix broken links"]
+
+
+def test_validate_chat_narrative_rejects_empty_arrays() -> None:
+ _, errors = validate_chat_narrative({
+ "power_insights": [],
+ "recommended_actions": [],
+ })
+ assert any("empty" in e for e in errors)
+
+
+def test_validate_chat_narrative_caps_items() -> None:
+ items = [f"item {i}" for i in range(8)]
+ narrative, errors = validate_chat_narrative({
+ "power_insights": items,
+ "recommended_actions": ["one"],
+ })
+ assert any("more than" in e for e in errors)
+ assert len(narrative["power_insights"]) == 5
+
+
+def test_build_synthesis_payload_truncates_large_tool_results() -> None:
+ huge = {"blob": "x" * 20000}
+ payload = build_synthesis_payload(
+ "overview?",
+ [{"name": "get_report_summary", "args": {}, "result": huge}],
+ )
+ assert len(payload) <= 10020
+ assert "truncated" in payload
+
+
+def test_synthesize_chat_narrative_success_first_attempt() -> None:
+ client = MagicMock()
+ client.complete_json.return_value = {
+ "power_insights": ["Insight"],
+ "recommended_actions": ["Action"],
+ }
+ with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client):
+ result = synthesize_chat_narrative(
+ {"llm_provider": "openai"},
+ "What is site health?",
+ [{"name": "get_report_summary", "result": {"health": 80}}],
+ )
+ assert result["power_insights"] == ["Insight"]
+ client.complete_json.assert_called_once()
+
+
+def test_synthesize_chat_narrative_retries_on_invalid_first_attempt() -> None:
+ client = MagicMock()
+ client.complete_json.side_effect = [
+ {"power_insights": []},
+ {"power_insights": ["Fixed"], "recommended_actions": ["Do it"]},
+ ]
+ statuses: list[str] = []
+ with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client):
+ result = synthesize_chat_narrative(
+ {"llm_provider": "openai"},
+ "overview",
+ [],
+ on_status=statuses.append,
+ )
+ assert result["power_insights"] == ["Fixed"]
+ assert client.complete_json.call_count == 2
+ assert statuses == ["synthesizing", "retrying"]
+
+
+def test_synthesize_chat_narrative_raises_after_two_failures() -> None:
+ client = MagicMock()
+ client.complete_json.side_effect = [
+ "not json",
+ {"recommended_actions": ["only actions"]},
+ ]
+ with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client):
+ with pytest.raises(ChatNarrativeError):
+ synthesize_chat_narrative({"llm_provider": "openai"}, "hi", [])
diff --git a/web/app/api/chat/route.ts b/web/app/api/chat/route.ts
index a87684c..062ac90 100644
--- a/web/app/api/chat/route.ts
+++ b/web/app/api/chat/route.ts
@@ -13,6 +13,7 @@ import {
} from '@/server/chatDb';
import { loadLlmConfig } from '@/server/llmConfig';
import type { ApiRouteHandler } from '@/types/api';
+import type { ChatNarrative } from '@/types/chatNarrative';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@@ -46,12 +47,21 @@ function sseLine(event: string, data: Record): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
}
+import type { ChatNarrative } from '@/types/chatNarrative';
+
function buildPersistedAssistantContent(
assistantText: string,
toolEvents: Array<{ name: string; args?: Record; result?: Record }>,
+ narrative: ChatNarrative | null,
sawError: boolean,
lastErrorMessage: string,
): string | null {
+ if (narrative) {
+ if (toolEvents.length > 0) {
+ return 'Tool results from this turn are shown below.';
+ }
+ return '';
+ }
const text = assistantText.trim();
if (text) return text;
if (toolEvents.length > 0) {
@@ -119,6 +129,7 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise;
@@ -193,6 +204,7 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise;
result?: Record;
+ narrative?: ChatNarrative;
};
if (evt.type === 'token' && evt.text) {
assistantText += evt.text;
@@ -220,9 +232,14 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise);
- } else if (evt.type === 'done' && evt.message) {
- assistantText = evt.message;
- push('done', { message: evt.message });
+ } else if (evt.type === 'narrative' && evt.narrative) {
+ narrative = evt.narrative;
+ push('narrative', { narrative: evt.narrative });
+ } else if (evt.type === 'done') {
+ if (evt.message) {
+ assistantText = evt.message;
+ }
+ push('done', { message: evt.message || '' });
} else if (evt.type === 'partial_done' && evt.message) {
assistantText = evt.message;
push('partial_done', { message: evt.message });
@@ -253,7 +270,7 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise l.trim())
@@ -277,22 +294,29 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise 0) {
try {
const toolResultPayload =
- toolEvents.length || (sawError && lastErrorMessage)
+ toolEvents.length || narrative || (sawError && lastErrorMessage)
? {
...(toolEvents.length ? { tool_events: toolEvents } : {}),
+ ...(narrative ? { narrative } : {}),
...(sawError && lastErrorMessage ? { agent_error: lastErrorMessage } : {}),
}
: null;
- await appendChatMessage(sessionId, 'assistant', contentToSave, {
- toolResult: toolResultPayload,
- });
+ await appendChatMessage(
+ sessionId,
+ 'assistant',
+ contentToSave ?? '',
+ {
+ toolResult: toolResultPayload,
+ },
+ );
if (session.title === 'New chat') {
const title = message.slice(0, 60) + (message.length > 60 ? '…' : '');
await updateChatSessionTitle(sessionId, title);
diff --git a/web/src/components/chat/ChatAssistantMessage.tsx b/web/src/components/chat/ChatAssistantMessage.tsx
index c2eedea..690c421 100644
--- a/web/src/components/chat/ChatAssistantMessage.tsx
+++ b/web/src/components/chat/ChatAssistantMessage.tsx
@@ -3,17 +3,20 @@
import { Sparkles } from 'lucide-react';
import ChatBlocks from '@/components/chat/blocks/ChatBlocks';
import ChatInsightSections from '@/components/chat/ChatInsightSections';
+import ChatNarrativeSections from '@/components/chat/ChatNarrativeSections';
import ChatToolActivity, { type ToolActivityItem } from '@/components/chat/ChatToolActivity';
import { preprocessChatMarkdown } from '@/components/chat/preprocessChatMarkdown';
import { postprocessChatContent } from '@/components/chat/postprocessChatContent';
import { sanitizeChatProse } from '@/components/chat/sanitizeChatProse';
import type { ChatBlock } from '@/components/chat/deriveChatBlocks';
+import type { ChatNarrative } from '@/types/chatNarrative';
import { strings } from '@/lib/strings';
const c = strings.components.chat;
export interface ChatAssistantMessageProps {
content: string;
+ narrative?: ChatNarrative;
toolActivity?: ToolActivityItem[];
blocks?: ChatBlock[];
streaming?: boolean;
@@ -25,6 +28,7 @@ export interface ChatAssistantMessageProps {
export default function ChatAssistantMessage({
content,
+ narrative,
toolActivity,
blocks: blocksOverride,
streaming,
@@ -33,15 +37,25 @@ export default function ChatAssistantMessage({
agentError,
statusText,
}: ChatAssistantMessageProps) {
- const processed = postprocessChatContent(content, toolActivity, {
- agentError,
- partialError,
- });
+ const useStructuredNarrative = Boolean(narrative);
+
+ const processed = postprocessChatContent(
+ useStructuredNarrative ? '' : content,
+ toolActivity,
+ {
+ agentError,
+ partialError: useStructuredNarrative ? false : partialError,
+ },
+ );
const blocks = blocksOverride ?? processed.blocks;
const prose = processed.prose;
- const showProse = prose.trim() && !processed.proseHidden;
+ const showProse = !useStructuredNarrative && prose.trim() && !processed.proseHidden;
const fatalError = Boolean(error && !partialError && !processed.hasPartialError);
const showPartialNote = processed.hasPartialError || partialError;
+ const showNarrative = Boolean(
+ narrative &&
+ (narrative.power_insights.length > 0 || narrative.recommended_actions.length > 0),
+ );
const cardClass = fatalError
? 'chat-assistant-card border-red-500/30 bg-red-500/10'
@@ -51,6 +65,7 @@ export default function ChatAssistantMessage({
const hasBody =
blocks.length > 0 ||
+ showNarrative ||
showProse ||
(toolActivity?.length ?? 0) > 0 ||
streaming ||
@@ -59,14 +74,14 @@ export default function ChatAssistantMessage({
if (!hasBody && fatalError) {
return (
- {content || c.responseFailed}
+ {content || agentError || c.responseFailed}
);
}
return (
- {(streaming || (!content && !blocks.length)) && !fatalError ? (
+ {(streaming || (!content && !blocks.length && !showNarrative)) && !fatalError ? (
{c.partialResponseNote}
) : null}
+ {showNarrative && narrative ? (
+
+ ) : null}
+
{showProse ? (
streaming && !prose.includes('###') ? (
{prose}
) : (
)
- ) : content.trim() && !blocks.length ? (
+ ) : !useStructuredNarrative && content.trim() && !blocks.length ? (
= [
+ { key: 'power_insights', title: 'Power Insights', icon: Lightbulb },
+ { key: 'recommended_actions', title: 'Recommended actions', ordered: true, icon: ListChecks },
+];
+
+export interface ChatNarrativeSectionsProps {
+ narrative: ChatNarrative;
+ streaming?: boolean;
+}
+
+function NarrativeList({
+ items,
+ ordered,
+ streaming,
+}: {
+ items: string[];
+ ordered?: boolean;
+ streaming?: boolean;
+}) {
+ const [showAll, setShowAll] = useState(false);
+ const visible = showAll ? items : items.slice(0, 5);
+ const Tag = ordered ? 'ol' : 'ul';
+ const listClass = ordered
+ ? 'list-decimal space-y-1.5 pl-5 text-muted-foreground'
+ : 'list-disc space-y-1.5 pl-5 text-muted-foreground';
+
+ return (
+
+
+ {visible.map((item, i) => (
+ {item}
+ ))}
+
+ {items.length > 5 && !showAll ? (
+
+ ) : null}
+
+ );
+}
+
+export default function ChatNarrativeSections({
+ narrative,
+ streaming,
+}: ChatNarrativeSectionsProps) {
+ const [open, setOpen] = useState>({});
+
+ const isOpen = (title: string) => {
+ if (title in open) return open[title];
+ return isExpandedSectionTitle(title);
+ };
+
+ const toggle = (title: string) => {
+ setOpen((prev) => ({ ...prev, [title]: !isOpen(title) }));
+ };
+
+ const sections = SECTIONS.filter((s) => narrative[s.key].length > 0);
+ if (!sections.length) return null;
+
+ return (
+
+ {sections.map((section) => {
+ const expanded = isOpen(section.title);
+ const Icon = section.icon;
+ return (
+
+
+ {expanded ? (
+
+
+
+ ) : null}
+
+ );
+ })}
+
+ );
+}
diff --git a/web/src/components/chat/parseChatSse.test.ts b/web/src/components/chat/parseChatSse.test.ts
index 108c812..4b9b2f9 100644
--- a/web/src/components/chat/parseChatSse.test.ts
+++ b/web/src/components/chat/parseChatSse.test.ts
@@ -28,4 +28,15 @@ describe('parseSseChunk', () => {
expect(events).toHaveLength(0);
expect(rest).toContain('event: token');
});
+
+ it('parses narrative events', () => {
+ const chunk =
+ 'event: narrative\ndata: {"narrative":{"power_insights":["A"],"recommended_actions":["B"]}}\n\n';
+ const { events } = parseSseChunk(chunk);
+ expect(events).toHaveLength(1);
+ expect(events[0]).toEqual({
+ type: 'narrative',
+ narrative: { power_insights: ['A'], recommended_actions: ['B'] },
+ });
+ });
});
diff --git a/web/src/components/chat/parseChatSse.ts b/web/src/components/chat/parseChatSse.ts
index 716f940..d3ad59c 100644
--- a/web/src/components/chat/parseChatSse.ts
+++ b/web/src/components/chat/parseChatSse.ts
@@ -3,6 +3,7 @@ export type ChatSseEvent =
| { type: 'status'; phase?: string; detail?: string }
| { type: 'tool_start'; name?: string; args?: Record }
| { type: 'tool_end'; name?: string; result?: Record }
+ | { type: 'narrative'; narrative: { power_insights: string[]; recommended_actions: string[] } }
| { type: 'done'; message?: string }
| { type: 'partial_done'; message?: string }
| { type: 'error'; message?: string };
@@ -46,6 +47,18 @@ export function parseSseChunk(buffer: string): { events: ChatSseEvent[]; rest: s
name: String(data.name || ''),
result: (data.result as Record) || {},
});
+ } else if (eventType === 'narrative') {
+ const narrative = data.narrative as Record | undefined;
+ const insights = Array.isArray(narrative?.power_insights)
+ ? (narrative.power_insights as unknown[]).map(String)
+ : [];
+ const actions = Array.isArray(narrative?.recommended_actions)
+ ? (narrative.recommended_actions as unknown[]).map(String)
+ : [];
+ events.push({
+ type: 'narrative',
+ narrative: { power_insights: insights, recommended_actions: actions },
+ });
} else if (eventType === 'done') {
events.push({ type: 'done', message: String(data.message || '') });
} else if (eventType === 'partial_done') {
diff --git a/web/src/strings.json b/web/src/strings.json
index bfe5229..0a50f49 100644
--- a/web/src/strings.json
+++ b/web/src/strings.json
@@ -3868,6 +3868,9 @@
"queryingData": "Querying audit data…",
"sending": "Sending your message…",
"writing": "Writing response…",
+ "synthesizing": "Summarizing insights…",
+ "synthesizingRetry": "Retrying summary…",
+ "narrativeFailed": "Could not generate a summary. Tool results are shown below.",
"toolStatus": "Running {name}…",
"elapsed": "{seconds}s",
"emptyResponse": "The assistant returned no response. Check AI settings, Ollama connection, and that LLM dependencies are installed (pip install -r requirements.txt).",
diff --git a/web/src/types/chatNarrative.test.ts b/web/src/types/chatNarrative.test.ts
new file mode 100644
index 0000000..a9bc4b2
--- /dev/null
+++ b/web/src/types/chatNarrative.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest';
+import { isChatNarrative, narrativeFromToolResult } from '@/types/chatNarrative';
+
+describe('chatNarrative types', () => {
+ it('validates narrative shape', () => {
+ expect(
+ isChatNarrative({
+ power_insights: ['one'],
+ recommended_actions: [],
+ }),
+ ).toBe(true);
+ expect(isChatNarrative({ power_insights: [], recommended_actions: [] })).toBe(false);
+ expect(isChatNarrative(null)).toBe(false);
+ });
+
+ it('reads narrative from tool_result', () => {
+ const narrative = narrativeFromToolResult({
+ tool_events: [],
+ narrative: {
+ power_insights: ['x'],
+ recommended_actions: ['y'],
+ },
+ });
+ expect(narrative?.power_insights).toEqual(['x']);
+ });
+});
diff --git a/web/src/types/chatNarrative.ts b/web/src/types/chatNarrative.ts
new file mode 100644
index 0000000..fe0ee87
--- /dev/null
+++ b/web/src/types/chatNarrative.ts
@@ -0,0 +1,25 @@
+export interface ChatNarrative {
+ power_insights: string[];
+ recommended_actions: string[];
+}
+
+export function isChatNarrative(value: unknown): value is ChatNarrative {
+ if (!value || typeof value !== 'object') return false;
+ const v = value as Record;
+ const insights = v.power_insights;
+ const actions = v.recommended_actions;
+ if (!Array.isArray(insights) || !Array.isArray(actions)) return false;
+ return (
+ insights.every((item) => typeof item === 'string' && item.trim().length > 0) &&
+ actions.every((item) => typeof item === 'string' && item.trim().length > 0) &&
+ (insights.length > 0 || actions.length > 0)
+ );
+}
+
+export function narrativeFromToolResult(
+ toolResult: Record | null | undefined,
+): ChatNarrative | undefined {
+ if (!toolResult) return undefined;
+ const raw = toolResult.narrative;
+ return isChatNarrative(raw) ? raw : undefined;
+}
diff --git a/web/src/views/Chat.tsx b/web/src/views/Chat.tsx
index 9727c52..9698ac9 100644
--- a/web/src/views/Chat.tsx
+++ b/web/src/views/Chat.tsx
@@ -9,6 +9,7 @@ import ChatShell from '@/components/chat/ChatShell';
import ChatSidebar from '@/components/chat/ChatSidebar';
import ChatMessageList, {
agentErrorFromToolResult,
+ narrativeFromToolResult,
type ChatMessage,
} from '@/components/chat/ChatMessageList';
import ChatComposer from '@/components/chat/ChatComposer';
@@ -23,6 +24,7 @@ import { apiUrl } from '@/lib/publicBase';
import { format, strings } from '@/lib/strings';
import { consumeChatSse } from '@/components/chat/parseChatSse';
import { toolEventsToActivity } from '@/components/chat/deriveChatBlocks';
+import type { ChatNarrative } from '@/types/chatNarrative';
import type { ToolActivityItem } from '@/components/chat/ChatToolActivity';
import {
buildChatSearchQuery,
@@ -198,10 +200,12 @@ export default function ChatPage() {
.map((m) => {
const toolActivity = toolEventsToActivity(m.tool_result);
const agentError = agentErrorFromToolResult(m.tool_result);
+ const narrative = narrativeFromToolResult(m.tool_result);
return {
id: m.id,
role: m.role as 'user' | 'assistant',
content: m.content,
+ narrative,
toolActivity: toolActivity.length ? toolActivity : undefined,
partialError: Boolean(agentError && toolActivity.length > 0),
agentError,
@@ -348,6 +352,7 @@ export default function ChatPage() {
}
let content = '';
+ let narrative: ChatNarrative | undefined;
let streamError = '';
const tools: ToolActivityItem[] = [];
@@ -387,9 +392,22 @@ export default function ChatPage() {
toolActivity: [...tools],
streaming: true,
});
- } else if (evt.type === 'done' && evt.message) {
- content = evt.message;
- patchAssistant({ content: evt.message, streaming: true, error: false, partialError: false });
+ } else if (evt.type === 'narrative') {
+ narrative = evt.narrative;
+ patchAssistant({
+ narrative: evt.narrative,
+ streaming: true,
+ error: false,
+ partialError: false,
+ });
+ } else if (evt.type === 'done') {
+ patchAssistant({
+ content: evt.message || content,
+ narrative,
+ streaming: true,
+ error: false,
+ partialError: false,
+ });
} else if (evt.type === 'partial_done' && evt.message) {
content = evt.message;
patchAssistant({
@@ -407,6 +425,7 @@ export default function ChatPage() {
content.trim() || (hasTools ? c.partialToolsSaved : streamError);
patchAssistant({
content: fallbackContent,
+ narrative,
streaming: false,
error: !hasTools,
partialError: hasTools,
@@ -418,8 +437,12 @@ export default function ChatPage() {
});
if (!streamError) {
+ const hasNarrative = Boolean(
+ narrative &&
+ (narrative.power_insights.length > 0 || narrative.recommended_actions.length > 0),
+ );
const finalContent = content.trim();
- if (!finalContent) {
+ if (!hasNarrative && !finalContent && tools.length === 0) {
const emptyMsg = c.emptyResponse;
setError(emptyMsg);
patchAssistant({
@@ -431,6 +454,7 @@ export default function ChatPage() {
} else {
patchAssistant({
content: finalContent,
+ narrative,
streaming: false,
error: false,
partialError: false,
@@ -439,6 +463,7 @@ export default function ChatPage() {
}
} else if (tools.length > 0) {
patchAssistant({
+ narrative,
streaming: false,
partialError: true,
agentError: streamError,
From b5757fdf1a3ebfe3bfd1521ef4f2a43982afd5fc Mon Sep 17 00:00:00 2001
From: PrashantUnity
Date: Wed, 17 Jun 2026 03:00:53 +0530
Subject: [PATCH 10/11] okay
---
.../components/charts/LighthouseScoreGrid.tsx | 7 +-
.../charts/compact/CompactAreaSparkline.tsx | 57 ++++
.../charts/compact/CompactBarChart.tsx | 107 +++++++
.../charts/compact/CompactDonut.tsx | 68 +++++
.../charts/compact/CompactHorizontalBars.tsx | 36 +++
.../components/charts/compact/CompactKpi.tsx | 27 ++
.../charts/compact/CompactStackedBar.tsx | 32 ++
.../charts/compact/CompactWidget.tsx | 18 ++
web/src/components/charts/compact/index.ts | 8 +
web/src/components/charts/index.ts | 10 +
.../components/landing/LandingProductMock.tsx | 285 +++---------------
web/src/components/lighthouse/ScoreRing.tsx | 20 +-
web/src/components/lighthouse/index.ts | 1 +
.../components/overview/OverviewAtAGlance.tsx | 198 ++++++++++++
.../components/overview/OverviewChartsTab.tsx | 22 ++
.../overview/OverviewContentQuality.tsx | 38 +--
.../overview/OverviewExecutiveSummary.tsx | 67 +---
.../overview/OverviewSummaryTab.tsx | 18 +-
.../overview/contentQualityMetrics.test.ts | 27 ++
.../overview/contentQualityMetrics.ts | 56 ++++
web/src/components/overview/index.ts | 1 +
.../overview/overviewAtAGlanceMetrics.test.ts | 86 ++++++
.../overview/overviewAtAGlanceMetrics.ts | 113 +++++++
web/src/strings.json | 7 +
web/src/views/Overview.tsx | 1 +
25 files changed, 979 insertions(+), 331 deletions(-)
create mode 100644 web/src/components/charts/compact/CompactAreaSparkline.tsx
create mode 100644 web/src/components/charts/compact/CompactBarChart.tsx
create mode 100644 web/src/components/charts/compact/CompactDonut.tsx
create mode 100644 web/src/components/charts/compact/CompactHorizontalBars.tsx
create mode 100644 web/src/components/charts/compact/CompactKpi.tsx
create mode 100644 web/src/components/charts/compact/CompactStackedBar.tsx
create mode 100644 web/src/components/charts/compact/CompactWidget.tsx
create mode 100644 web/src/components/charts/compact/index.ts
create mode 100644 web/src/components/overview/OverviewAtAGlance.tsx
create mode 100644 web/src/components/overview/overviewAtAGlanceMetrics.test.ts
create mode 100644 web/src/components/overview/overviewAtAGlanceMetrics.ts
diff --git a/web/src/components/charts/LighthouseScoreGrid.tsx b/web/src/components/charts/LighthouseScoreGrid.tsx
index ec786a9..5234ae3 100644
--- a/web/src/components/charts/LighthouseScoreGrid.tsx
+++ b/web/src/components/charts/LighthouseScoreGrid.tsx
@@ -1,4 +1,4 @@
-import { ScoreRing } from '@/components/lighthouse';
+import { ScoreRing, type ScoreRingSize } from '@/components/lighthouse';
import { ChartAccessibleFallback } from './ChartAccessibleFallback';
const LH_CAT_ORDER = ['performance', 'accessibility', 'best-practices', 'seo'] as const;
@@ -7,10 +7,11 @@ export interface LighthouseScoreGridProps {
scores: Record;
categoryLabels: Record;
aria: string;
+ size?: ScoreRingSize;
}
/** Lighthouse category scores as ScoreRings (0–100 ratings, not count bars). */
-export function LighthouseScoreGrid({ scores, categoryLabels, aria }: LighthouseScoreGridProps) {
+export function LighthouseScoreGrid({ scores, categoryLabels, aria, size = 'md' }: LighthouseScoreGridProps) {
const rows = LH_CAT_ORDER.map((id) => {
const label = categoryLabels[id] || id.replace('-', ' ');
const score = scores[id] != null ? Number(scores[id]) : null;
@@ -23,7 +24,7 @@ export function LighthouseScoreGrid({ scores, categoryLabels, aria }: Lighthouse
{LH_CAT_ORDER.map((id) => {
const label = categoryLabels[id] || id.replace('-', ' ');
const score = scores[id] != null ? Number(scores[id]) : null;
- return ;
+ return ;
})}
diff --git a/web/src/components/charts/compact/CompactAreaSparkline.tsx b/web/src/components/charts/compact/CompactAreaSparkline.tsx
new file mode 100644
index 0000000..dcf5c41
--- /dev/null
+++ b/web/src/components/charts/compact/CompactAreaSparkline.tsx
@@ -0,0 +1,57 @@
+'use client';
+
+import { useId } from 'react';
+
+export interface CompactAreaSparklineProps {
+ points: number[];
+ className?: string;
+ heightClass?: string;
+ strokeClassName?: string;
+}
+
+export function CompactAreaSparkline({
+ points,
+ className = '',
+ heightClass = 'h-8',
+ strokeClassName = 'text-link/70',
+}: CompactAreaSparklineProps) {
+ const fillId = useId();
+ if (points.length < 2) return null;
+
+ const max = Math.max(...points);
+ const min = Math.min(...points);
+ const range = max - min || 1;
+ const coords = points
+ .map((p, i) => {
+ const x = (i / (points.length - 1)) * 100;
+ const y = 100 - ((p - min) / range) * 100;
+ return `${x},${y}`;
+ })
+ .join(' ');
+
+ return (
+
+ );
+}
diff --git a/web/src/components/charts/compact/CompactBarChart.tsx b/web/src/components/charts/compact/CompactBarChart.tsx
new file mode 100644
index 0000000..47e27b7
--- /dev/null
+++ b/web/src/components/charts/compact/CompactBarChart.tsx
@@ -0,0 +1,107 @@
+import type { CSSProperties } from 'react';
+
+export type CompactBarChartVariant = 'default' | 'chubby';
+
+export interface CompactBarChartProps {
+ /** Heights as percentages 0–100; length determines bar count */
+ heights: number[];
+ /** Optional labels rendered under each bar (chubby variant) */
+ labels?: string[];
+ /** Optional per-bar fill colors (hex or rgb) */
+ colors?: string[];
+ variant?: CompactBarChartVariant;
+ className?: string;
+ heightClass?: string;
+}
+
+const DEFAULT_BAR =
+ 'min-w-0 flex-1 rounded-sm bg-gradient-to-t from-blue-600/55 to-blue-400/25';
+const CHUBBY_BAR_BASE =
+ 'w-10 shrink-0 rounded-lg shadow-sm sm:w-12';
+
+function barFillStyle(color: string | undefined, variant: CompactBarChartVariant): CSSProperties {
+ if (color) {
+ return {
+ background: `linear-gradient(to top, ${color}dd, ${color}66)`,
+ };
+ }
+ return {};
+}
+
+function barClassName(color: string | undefined, variant: CompactBarChartVariant): string {
+ if (variant === 'chubby') {
+ return color ? CHUBBY_BAR_BASE : `${CHUBBY_BAR_BASE} bg-gradient-to-t from-blue-600/70 to-blue-400/35`;
+ }
+ return color ? 'min-w-0 flex-1 rounded-sm' : DEFAULT_BAR;
+}
+
+export function CompactBarChart({
+ heights,
+ labels,
+ colors,
+ variant = 'default',
+ className = '',
+ heightClass = 'h-20',
+}: CompactBarChartProps) {
+ if (!heights.length) return null;
+
+ const minPercent = variant === 'chubby' ? 18 : 4;
+ const plotHeightClass = variant === 'chubby' ? 'h-28' : heightClass;
+
+ if (variant === 'chubby') {
+ return (
+
+
+ {heights.map((h, i) => {
+ const pct = Math.min(100, Math.max(minPercent, h));
+ const color = colors?.[i];
+ return (
+
+
+ {labels?.[i] ? (
+
+ {labels[i]}
+
+ ) : null}
+
+ );
+ })}
+
+
+ );
+ }
+
+ return (
+
+ {heights.map((h, i) => {
+ const color = colors?.[i];
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/web/src/components/charts/compact/CompactDonut.tsx b/web/src/components/charts/compact/CompactDonut.tsx
new file mode 100644
index 0000000..70163f7
--- /dev/null
+++ b/web/src/components/charts/compact/CompactDonut.tsx
@@ -0,0 +1,68 @@
+export interface CompactDonutSegment {
+ label: string;
+ value: number;
+ color: string;
+}
+
+export interface CompactDonutProps {
+ segments: CompactDonutSegment[];
+ centerLabel?: string;
+ centerValue?: string;
+ /** When true, legend shows raw counts; otherwise percentages of total */
+ showCounts?: boolean;
+ /** Tailwind size classes for the donut ring (default compact h-14 w-14) */
+ ringClassName?: string;
+}
+
+export function CompactDonut({
+ segments,
+ centerLabel,
+ centerValue,
+ showCounts = false,
+ ringClassName = 'h-14 w-14',
+}: CompactDonutProps) {
+ const total = segments.reduce((sum, s) => sum + s.value, 0);
+ if (total <= 0) return null;
+
+ let cumulative = 0;
+ const gradient = segments
+ .map((s) => {
+ const start = (cumulative / total) * 100;
+ cumulative += s.value;
+ const end = (cumulative / total) * 100;
+ return `${s.color} ${start}% ${end}%`;
+ })
+ .join(', ');
+
+ return (
+
+
+
+
+ {centerValue ? (
+ {centerValue}
+ ) : null}
+ {centerLabel ? (
+ {centerLabel}
+ ) : null}
+
+
+
+ {segments.map((s) => {
+ const pct = Math.round((s.value / total) * 100);
+ return (
+ -
+
+
+ {s.label}
+
+
+ {showCounts ? s.value.toLocaleString() : `${pct}%`}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/web/src/components/charts/compact/CompactHorizontalBars.tsx b/web/src/components/charts/compact/CompactHorizontalBars.tsx
new file mode 100644
index 0000000..d1566fd
--- /dev/null
+++ b/web/src/components/charts/compact/CompactHorizontalBars.tsx
@@ -0,0 +1,36 @@
+export interface CompactHorizontalBarItem {
+ label: string;
+ value: number;
+ color: string;
+}
+
+export interface CompactHorizontalBarsProps {
+ items: CompactHorizontalBarItem[];
+}
+
+export function CompactHorizontalBars({ items }: CompactHorizontalBarsProps) {
+ if (!items.length) return null;
+ const max = Math.max(...items.map((i) => i.value));
+
+ return (
+