Skip to content
Merged
75 changes: 60 additions & 15 deletions src/app/api/embed/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { resolveProjectFromApiKey } from "@/lib/embedStore";
import { checkEmbedRateLimit } from "@/lib/embedRateLimit";
import { NORTHSTAR_DEMO_PROJECT_ID } from "@/lib/embedDemoKnowledge";
import { isServerLlmConfigured, runNorthstarEmbedChat } from "@/lib/embedNorthstarChat";
import { getNextAction, type DemoAppState } from "@/lib/embedDemoNextAction";
import {
bulletsFromText,
clipWords,
Expand Down Expand Up @@ -65,6 +66,7 @@ type ChatBody = {
/** Optional imported repository docs from Studio/embed demo. */
documents?: unknown;
hoveredFeature?: unknown;
appState?: unknown;
};

type StructuredChatPayload = {
Expand All @@ -73,6 +75,7 @@ type StructuredChatPayload = {
steps: string[];
sources: { title: string; excerpt?: string; url?: string }[];
suggestions: string[];
uiAction?: { type: "start_tour" | "start_step" | "highlight"; stepId?: string; feature?: string } | null;
};

function sanitizeCustomSources(raw: unknown): { title: string; content: string }[] {
Expand Down Expand Up @@ -107,17 +110,18 @@ function sanitizeDocuments(raw: unknown): { title: string; content: string }[] {

function normalizePageContext(body: ChatBody): string {
const hovered = sanitizeHoveredFeature(body.hoveredFeature);
if (typeof body.pageContext === "string" && body.pageContext.trim()) {
return [
`Page URL: ${body.pageUrl || "n/a"}`,
`Page title: ${body.pageTitle || "n/a"}`,
body.pageContext.trim()
]
.filter(Boolean)
.join("\n");
}
const parts = [body.pageUrl, body.pageTitle, hovered].filter(Boolean) as string[];
return parts.join("\n");
const appState =
body.appState && typeof body.appState === "object" ? JSON.stringify(body.appState).slice(0, 400) : "(none)";
const pageBody = typeof body.pageContext === "string" ? body.pageContext.trim() : "";
const pageSection = [
"Page context:",
`Page URL: ${body.pageUrl || "n/a"}`,
`Page title: ${body.pageTitle || "n/a"}`,
pageBody || "(none)"
].join("\n");
const hoverSection = ["Hovered feature context:", hovered || "(none)"].join("\n");
const appStateSection = ["App state:", appState].join("\n");
return [pageSection, appStateSection, hoverSection].join("\n\n");
}

function sanitizeHoveredFeature(raw: unknown): string {
Expand All @@ -144,10 +148,48 @@ function normalizeStructured(payload: Partial<StructuredChatPayload>): Structure
bullets: normalizedBullets.length > 0 ? normalizedBullets : normalizeBullets(fallbackBullets),
steps: normalizeSteps(payload.steps || []),
sources: (payload.sources || []).slice(0, MAX_SOURCES),
suggestions: normalizeSuggestions(payload.suggestions || DEFAULT_SUGGESTIONS)
suggestions: normalizeSuggestions(payload.suggestions || DEFAULT_SUGGESTIONS),
uiAction: payload.uiAction ?? null
};
}

function nextStepFromAppState(appState: unknown): { feature: string; stepId: string } {
const raw = appState && typeof appState === "object" ? (appState as Partial<DemoAppState>) : {};
const normalized: DemoAppState = {
githubConnected: Boolean(raw.githubConnected),
apiKeyCreated: Boolean(raw.apiKeyCreated),
workflowCreated: Boolean(raw.workflowCreated),
deployed: Boolean(raw.deployed)
};
const next = getNextAction(normalized);
if (next.id === "connect-github") return { feature: "integrations", stepId: "connect-github" };
if (next.id === "create-api-key") return { feature: "api-keys", stepId: "create-api-key" };
if (next.id === "build-workflow") return { feature: "workflow-builder", stepId: "build-workflow" };
if (next.id === "deploy") return { feature: "deployments", stepId: "deploy-staging" };
return { feature: "workflow-builder", stepId: "build-workflow" };
}

function deriveUiAction(message: string, appState: unknown): StructuredChatPayload["uiAction"] {
const m = String(message || "").toLowerCase();
if (/start guided tour|guide me|step by step/.test(m)) {
const next = nextStepFromAppState(appState);
return { type: "start_step", stepId: next.stepId, feature: next.feature };
}
if (/what should i do next|what can i do next|next step/.test(m)) {
const next = nextStepFromAppState(appState);
return { type: "highlight", feature: next.feature, stepId: next.stepId };
}
if (/\b(connect|link)\s+github\b/.test(m)) return { type: "start_step", stepId: "connect-github", feature: "integrations" };
if (/\b(create|generate)\s+(an?\s+)?api\s*key\b/.test(m) || /\b(create|generate)\s+key\b/.test(m)) {
return { type: "start_step", stepId: "create-api-key", feature: "api-keys" };
}
if (/build workflow|create workflow/.test(m)) return { type: "start_step", stepId: "build-workflow", feature: "workflow-builder" };
if (/\b(deploy(?:\s+now)?|push\s+to\s+staging|deploy\s+to\s+staging)\b/.test(m)) {
return { type: "start_step", stepId: "deploy-staging", feature: "deployments" };
}
return null;
}

function parseStructuredFromRaw(raw: string): Partial<StructuredChatPayload> {
const trimmed = raw.trim();
const idx = trimmed.lastIndexOf("RUNBOOK_JSON:");
Expand Down Expand Up @@ -178,6 +220,7 @@ export async function POST(req: NextRequest) {
const message = String(body.message || body.question || "").trim();
const pageContext = normalizePageContext(body);
const requestDocs = sanitizeDocuments(body.documents);
const uiAction = deriveUiAction(message, body.appState);

if (!projectId) {
return NextResponse.json({ error: "projectId is required" }, { status: 400, headers: corsHeaders(origin) });
Expand All @@ -201,9 +244,10 @@ export async function POST(req: NextRequest) {
message,
pageContext,
hoveredFeature: sanitizeHoveredFeature(body.hoveredFeature),
appState: body.appState,
customSources
});
return NextResponse.json(normalizeStructured(payload), { headers: corsHeaders(origin) });
return NextResponse.json(normalizeStructured({ ...payload, uiAction }), { headers: corsHeaders(origin) });
}

const auth = req.headers.get("authorization") || "";
Expand Down Expand Up @@ -293,15 +337,16 @@ RUNBOOK_JSON: {"answer":"<=10 words","bullets":["<=12 words"],"steps":["2-4 conc
sources: baseSources
});

return NextResponse.json(normalized, { headers: corsHeaders(origin) });
return NextResponse.json({ ...normalized, uiAction }, { headers: corsHeaders(origin) });
} catch (e) {
console.error(e);
return NextResponse.json(
normalizeStructured({
answer: "AI unavailable; showing grounded fallback.",
bullets: ["Use source cards below", "Follow step mode checklist", "Retry in a moment"],
sources: baseSources,
steps: ["Review the top source card.", "Complete the first checklist step.", "Retry your question."]
steps: ["Review the top source card.", "Complete the first checklist step.", "Retry your question."],
uiAction
}),
{ status: 503, headers: corsHeaders(origin) }
);
Expand Down
67 changes: 49 additions & 18 deletions src/app/embed-demo/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,20 @@ function compactSentence(text: string, max = 170): string {
return text.replace(/\s+/g, " ").trim().slice(0, max);
}

function firstSentence(text: string): string {
const clean = String(text || "").replace(/\s+/g, " ").trim();
const abbreviationPattern = /\b(?:e\.g|i\.e|mr|mrs|ms|dr|prof)\.$/i;
const sentenceEndPattern = /[.!?](?:\s|$)/g;
let match: RegExpExecArray | null;
while ((match = sentenceEndPattern.exec(clean)) !== null) {
const candidate = clean.slice(0, match.index + 1).trim();
if (candidate.split(/\s+/).length < 3) continue;
if (abbreviationPattern.test(candidate)) continue;
return candidate;
}
return clean;
}

function tokenize(text: string): string[] {
return String(text)
.toLowerCase()
Expand Down Expand Up @@ -160,36 +174,53 @@ function buildFeatureExplanationMap(docs: ImportedDocument[]): Record<string, st
const bestSentence = extractPrimarySentence(sourceDoc.content || "", hints);
if (!bestSentence) continue;
const signals = extractCodeSignals(sourceDoc);
const signalText = signals.length > 0 ? ` ${signals.join(" · ")}.` : "";
out[feature] = `${compactSentence(bestSentence)}.${signalText}`.trim();
const signalText = signals.length > 0 ? ` (${signals.join(" · ")})` : "";
out[feature] = firstSentence(`${compactSentence(bestSentence)}${signalText}`);
}
return out;
}

export default function EmbedDemoLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const initialAgent = useMemo(() => getInitialAgentFromUrl(), []);
const [bundle, setBundle] = useState<DemoBundle>(() => (initialAgent ? initialAgent.assistantConfig : loadDemoBundle()));
const [projectId, setProjectId] = useState(() => (initialAgent ? initialAgent.projectId : loadProjectId()));
const [repoInfo, setRepoInfo] = useState<ImportedRepoInfo | null>(() => (initialAgent ? initialAgent.repo : loadImportedRepo()));
const [importedDocs, setImportedDocs] = useState<ImportedDocument[]>(() => (initialAgent ? initialAgent.documents : loadImportedDocs()));
const [savedAgents, setSavedAgents] = useState<TestAgentProfile[]>(() => loadTestAgents());
const [bundle, setBundle] = useState<DemoBundle>({
assistantName: "Runbook Assistant",
welcome: "I can help you get started quickly.",
primaryColor: "#6366f1",
suggestedQuestions: ["What can I do here?", "Guide me step-by-step", "What should I do next?"],
manualSources: []
});
const [projectId, setProjectId] = useState("northstar-demo");
const [repoInfo, setRepoInfo] = useState<ImportedRepoInfo | null>(null);
const [importedDocs, setImportedDocs] = useState<ImportedDocument[]>([]);
const [savedAgents, setSavedAgents] = useState<TestAgentProfile[]>([]);
const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null);
const [hintsEnabled, setHintsEnabled] = useState<boolean>(() => getStoredBoolean(HINTS_TOGGLE_KEY, true));
const [assistantEnabled, setAssistantEnabled] = useState<boolean>(() => getStoredBoolean(ASSISTANT_TOGGLE_KEY, true));
const [hintsEnabled, setHintsEnabled] = useState<boolean>(true);
const [assistantEnabled, setAssistantEnabled] = useState<boolean>(true);
const hoverWrapRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
const refresh = () => {
setBundle(loadDemoBundle());
setProjectId(loadProjectId());
setRepoInfo(loadImportedRepo());
setImportedDocs(loadImportedDocs());
const initialAgent = getInitialAgentFromUrl();
if (initialAgent) {
setBundle(initialAgent.assistantConfig);
setProjectId(initialAgent.projectId);
setRepoInfo(initialAgent.repo);
setImportedDocs(initialAgent.documents);
} else {
setBundle(loadDemoBundle());
setProjectId(loadProjectId());
setRepoInfo(loadImportedRepo());
setImportedDocs(loadImportedDocs());
}
setSavedAgents(loadTestAgents());
setHintsEnabled(getStoredBoolean(HINTS_TOGGLE_KEY, true));
setAssistantEnabled(getStoredBoolean(ASSISTANT_TOGGLE_KEY, true));
};
const initTimer = window.setTimeout(refresh, 0);
window.addEventListener("storage", refresh);
window.addEventListener("runbook-demo-update", refresh);
return () => {
window.clearTimeout(initTimer);
window.removeEventListener("storage", refresh);
window.removeEventListener("runbook-demo-update", refresh);
};
Expand Down Expand Up @@ -283,7 +314,7 @@ export default function EmbedDemoLayout({ children }: { children: React.ReactNod
</div>
</div>
<div className="hidden rounded-full border border-indigo-400/30 bg-indigo-500/10 px-3 py-1 text-xs text-indigo-100 lg:block">
Active knowledge: <span className="font-semibold">{environmentLabel}</span>
Active knowledge: <span suppressHydrationWarning className="font-semibold">{environmentLabel}</span>
</div>
<div className="hidden items-center gap-2 md:flex">
<button
Expand All @@ -296,7 +327,7 @@ export default function EmbedDemoLayout({ children }: { children: React.ReactNod
}}
className="rounded-lg border border-white/20 px-2.5 py-1 text-[11px] text-slate-200 hover:border-white/40"
>
{hintsEnabled ? "Hints on" : "Hints off"}
<span suppressHydrationWarning>{hintsEnabled ? "Hints on" : "Hints off"}</span>
</button>
</div>
</div>
Expand Down Expand Up @@ -395,7 +426,7 @@ export default function EmbedDemoLayout({ children }: { children: React.ReactNod
}}
className="rounded-full border border-white/25 bg-slate-900/90 px-3 py-1.5 text-xs font-semibold text-slate-100 shadow-lg hover:border-white/50"
>
{hintsEnabled ? "Hints: on" : "Hints: off"}
<span suppressHydrationWarning>{hintsEnabled ? "Hints: on" : "Hints: off"}</span>
</button>
<button
type="button"
Expand All @@ -406,7 +437,7 @@ export default function EmbedDemoLayout({ children }: { children: React.ReactNod
}}
className="rounded-full border border-white/25 bg-slate-900/90 px-3 py-1.5 text-xs font-semibold text-slate-100 shadow-lg hover:border-white/50"
>
{assistantEnabled ? "Widget: on" : "Widget: off"}
<span suppressHydrationWarning>{assistantEnabled ? "Widget: on" : "Widget: off"}</span>
</button>
</div>

Expand Down
Loading
Loading