Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { getMaterialIcon } from "@repo/contents/_lib/curriculum/material";
import {
isMaterialContentRoute,
isMaterialLessonRoute,
readMaterialPagination,
readParentMaterialRoute,
toLocalizedContentHref,
} from "@repo/contents/_types/route/content";
import { readStaticPublicContentRoutes } from "@repo/contents/_types/route/content/static";
import { readStaticPublicLearningIndex } from "@repo/contents/_types/route/learning/static";
Expand Down Expand Up @@ -139,6 +141,33 @@ export function readMaterialHeaderLink(
});
}

/**
* Builds material sibling pagination while preserving a validated source context.
*
* Canonical visits and stale context hints use plain material URLs. Contextual
* visits keep the same source card identity on previous/next lesson links only
* when the current page and target sibling both validate against the index.
*/
export function readMaterialPagePagination(
route: PublicContentRoute,
context: MaterialContextIdentity | undefined
) {
const index = readStaticPublicLearningIndex();

if (!(context && index.resolveMaterialHeaderLink({ context, route }))) {
return readMaterialPagination(route, readMaterialRoutes());
}

return readMaterialPagination(route, readMaterialRoutes(), {
toHref: (targetRoute) =>
index.toContextualMaterialHref({
context,
href: toLocalizedContentHref(targetRoute),
route: targetRoute,
}),
});
}

/**
* Selects the subject-specific icon from one projected material source path.
*
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { LearningContextInput } from "@repo/backend/convex/contents/context";
import { getHeadings } from "@repo/contents/_lib/toc";
import { formatContentDateISO } from "@repo/contents/_shared/date";
import {
isMaterialLessonRoute,
readMaterialPagination,
toLocalizedContentHref,
} from "@repo/contents/_types/route/content";
import type { MaterialContextIdentity } from "@repo/contents/_types/route/material/reference";
import type { PublicContentRoute } from "@repo/contents/_types/route/schema";
import { ArticleJsonLd } from "@repo/seo/json-ld/article";
import { BreadcrumbJsonLd } from "@repo/seo/json-ld/breadcrumb";
Expand All @@ -19,6 +20,7 @@ import {
getProjectedMaterialIcon,
listMaterialStaticParams,
readMaterialHeaderLink,
readMaterialPagePagination,
readMaterialRoutes,
requireParentMaterialRoute,
resolveMaterialRoute,
Expand All @@ -33,8 +35,10 @@ import { LayoutMaterialContent } from "@/components/shared/material/content";
import { LayoutMaterial } from "@/components/shared/material/layout";
import { LayoutMaterialToc } from "@/components/shared/material/toc";
import { PaginationContent } from "@/components/shared/pagination-content";
import { ContentViewTracker } from "@/components/tracking/tracker";
import { importContentModuleOrNull } from "@/lib/content/module";
import { fetchRuntimeCurriculumPage } from "@/lib/content/runtime/pages";
import { getRuntimeContentViewId } from "@/lib/content/views";
import { readMaterialContextQuery } from "@/lib/routing/material/query";
import { getGithubUrl } from "@/lib/utils/github";
import { getOgUrl, getSocialMetadata } from "@/lib/utils/metadata";
Expand Down Expand Up @@ -100,7 +104,7 @@ export async function generateMetadata({
/**
* Renders the canonical material lesson page.
*
* Topic rows are grouping data for curriculum card pages. They intentionally do
* Topic rows are grouping data for curriculum card pages. They intentionally
* do not render public pages, so the learner opens concrete material content
* directly from a collapsible card.
*/
Expand Down Expand Up @@ -139,31 +143,47 @@ export default async function Page({

const Content = content.default;
const parentRoute = requireParentMaterialRoute(route);
const materialContext = readMaterialContextQuery(query ?? {});
const trackerContext: LearningContextInput | undefined = materialContext
? {
mode: "placement",
nodeKey: materialContext.nodeKey,
programKey: materialContext.programKey,
}
: undefined;
const contentId = await getRuntimeContentViewId({
locale,
route: route.publicPath,
});

return (
<MaterialLessonPage
content={runtimeLesson}
footer={<DeferredComments slug={route.sourcePath} />}
headerLink={readMaterialHeaderLink(
route,
readMaterialContextQuery(query ?? {})
)}
<ContentViewTracker
contentId={contentId}
context={trackerContext}
locale={locale}
parentTitle={parentRoute.title}
route={route}
toolbar={
<DeferredAiSheetOpen
audio={{
contentType: "material",
locale,
slug: route.sourcePath,
}}
contextTitle={runtimeLesson.metadata.title}
/>
}
>
<Content />
</MaterialLessonPage>
<MaterialLessonPage
content={runtimeLesson}
footer={<DeferredComments slug={route.sourcePath} />}
headerLink={readMaterialHeaderLink(route, materialContext)}
locale={locale}
materialContext={materialContext}
parentTitle={parentRoute.title}
route={route}
toolbar={
<DeferredAiSheetOpen
audio={{
contentType: "material",
locale,
slug: route.sourcePath,
}}
contextTitle={runtimeLesson.metadata.title}
/>
}
>
<Content />
</MaterialLessonPage>
</ContentViewTracker>
);
}

Expand All @@ -179,6 +199,7 @@ async function MaterialLessonPage({
footer,
headerLink,
locale,
materialContext,
parentTitle,
route,
toolbar,
Expand All @@ -191,6 +212,7 @@ async function MaterialLessonPage({
label: string;
};
locale: Locale;
materialContext: MaterialContextIdentity | undefined;
parentTitle: string;
route: PublicContentRoute;
toolbar: ReactNode;
Expand All @@ -200,7 +222,7 @@ async function MaterialLessonPage({
const raw = content.body;
const headings = getHeadings(raw);
const metadata = content.metadata;
const pagination = readMaterialPagination(route, readMaterialRoutes());
const pagination = readMaterialPagePagination(route, materialContext);
const publishedAt = Option.getOrElse(
formatContentDateISO(metadata.date),
() => metadata.date
Expand Down
170 changes: 170 additions & 0 deletions apps/www/app/api/chat/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import {
type NinaAgentChat as CoreNinaAgentChat,
type NinaAgentRuntime as CoreNinaAgentRuntime,
type NinaAgentUser as CoreNinaAgentUser,
createNinaAgentContext,
type NinaAgentPage,
runNinaAgentTurn,
} from "@repo/ai/nina/agent";
import type { MyUIMessage } from "@repo/ai/types/message";
import type { Id } from "@repo/backend/convex/_generated/dataModel";
import type { LogContext } from "@repo/utilities/logging/types";
import type { UIMessageStreamWriter } from "ai";
import { Effect } from "effect";
import type { getTranslations } from "next-intl/server";
import { createPageFetchState } from "@/app/api/chat/fetch";
import { recoverChatToolCall } from "@/app/api/chat/recovery";
import { prepareChatStep } from "@/app/api/chat/step";
import { writeSuggestions } from "@/app/api/chat/suggestions";
import { createNinaToolSet } from "@/app/api/chat/tools";
import { trackUsage } from "@/app/api/chat/usage";
import type { getLearningProfile, getUserInfo } from "@/app/api/chat/utils";

type Translator = Awaited<ReturnType<typeof getTranslations>>;
type LearningProfile = Effect.Effect.Success<
ReturnType<typeof getLearningProfile>
>;
type UserInfo = Effect.Effect.Success<ReturnType<typeof getUserInfo>>;

/** Prepared chat state needed by Nina's ToolLoopAgent turn. */
export interface NinaAgentChat extends CoreNinaAgentChat {
readonly id: Id<"chats">;
}

/** Runtime services and callbacks for Nina's ToolLoopAgent turn. */
export interface NinaAgentRuntime extends CoreNinaAgentRuntime {
readonly logContext: LogContext;
readonly reportError: (error: unknown, source: string) => void;
readonly translate: Translator;
}

/** User context exposed to Nina's ToolLoopAgent turn. */
export interface NinaAgentUser
extends Omit<CoreNinaAgentUser, "learningProfile" | "role"> {
readonly info: UserInfo;
readonly learningProfile: LearningProfile;
}

/** Inputs for the app-bound Nina ToolLoopAgent execution seam. */
export interface StreamNinaAgentInput {
readonly chat: NinaAgentChat;
readonly onStreamError: (error: unknown, source: string) => void;
readonly page: NinaAgentPage;
readonly runtime: NinaAgentRuntime;
readonly user: NinaAgentUser;
readonly writer: UIMessageStreamWriter<MyUIMessage>;
}

/** Converts app auth/profile rows into the package-owned Nina user context. */
function createCoreNinaUser(user: NinaAgentUser): CoreNinaAgentUser {
return {
location: user.location,
...(user.learningProfile ? { learningProfile: user.learningProfile } : {}),
...(user.info.role ? { role: user.info.role } : {}),
};
}

/** Formats AI SDK stream errors while preserving server-side diagnostics. */
function formatNinaStreamError({
error,
runtime,
}: {
readonly error: unknown;
readonly runtime: NinaAgentRuntime;
}) {
if (error instanceof Error) {
if (error.message.includes("Rate limit")) {
Effect.runFork(
Effect.logWarning("Rate limit exceeded in message stream").pipe(
Effect.annotateLogs(runtime.logContext)
)
);
return runtime.translate("rate-limit-message");
}

return error.message;
}

Effect.runFork(
Effect.logError("Unknown error in message stream").pipe(
Effect.annotateLogs(runtime.logContext)
)
);
return runtime.translate("error-message");
}

/** Streams one Nina ToolLoopAgent turn into the AI SDK UI writer. */
export const streamNinaAgent = Effect.fn("chat.streamNinaAgent")(function* ({
chat,
onStreamError,
page,
runtime,
user,
writer,
}: StreamNinaAgentInput) {
const usage = yield* trackUsage();
const coreUser = createCoreNinaUser(user);
const context = createNinaAgentContext({
page,
runtime,
user: coreUser,
});
const pageFetch = createPageFetchState(context.needsPageFetch);

const turn = yield* runNinaAgentTurn({
adapter: {
formatStreamError: (error) => formatNinaStreamError({ error, runtime }),
onStreamError,
prepareStep: ({ messages, stepNumber, system }) =>
Effect.runSync(
prepareChatStep({
messages,
needsPageFetch: page.needsFetch,
stepNumber,
system,
})
),
readFinishMetadata: (mainUsage) =>
Effect.runSync(
usage.metadata({
mainUsage,
modelId: runtime.modelId,
})
),
repairToolCall: (options) =>
Effect.runPromise(
recoverChatToolCall({
...options,
reservePageFetch: pageFetch.reserveForRepair,
sessionLogger: runtime.logContext,
url: page.url,
})
),
tools: createNinaToolSet({
context,
locale: page.locale,
logContext: runtime.logContext,
modelId: runtime.modelId,
consumePageFetch: pageFetch.consumeForTool,
reportError: runtime.reportError,
usage,
writer,
}),
writer,
},
chat,
page,
runtime,
user: coreUser,
});

yield* writeSuggestions({
locale: page.locale,
messages: [...chat.finalMessages, ...turn.messages],
writer,
}).pipe(
Effect.catchAll((error) =>
Effect.sync(() => runtime.reportError(error, "writeSuggestions"))
)
);
});
Loading
Loading