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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const nextConfig: NextConfig = {
.filter(Boolean),
// Share and embed links, the Studio, history and sign-in say noindex on
// every response, redirects and errors included (src/lib/crawl.ts).
// src/proxy.ts sends /data's.
async headers() {
return noindexHeaders();
},
Expand Down
47 changes: 22 additions & 25 deletions src/app/(studio)/insights/gallery/[slug]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { ImageResponse } from 'next/og';

import { GALLERY_ITEMS, galleryItem } from '@/components/organisms/Gallery/galleryData';
import { CARD_SIZE, CARD_TYPE } from '@/lib/site';

export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export const size = CARD_SIZE;
export const contentType = CARD_TYPE;

export const dynamicParams = false;

Expand All @@ -19,32 +20,28 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> })
const item = galleryItem((await params).slug);

return new ImageResponse(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
width: '100%',
height: '100%',
padding: 72,
backgroundColor: '#f6f6e8',
color: '#484842',
}}
>
<div style={{ display: 'flex', fontSize: 28, letterSpacing: -0.5 }}>
DFO Gallery
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
width: '100%',
height: '100%',
padding: 72,
backgroundColor: '#f6f6e8',
color: '#484842',
}}
>
<div style={{ display: 'flex', fontSize: 28, letterSpacing: -0.5 }}>DFO Gallery</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div style={{ display: 'flex', fontSize: 68, lineHeight: 1.05 }}>
{item?.title ?? 'DFO Studio session'}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div style={{ display: 'flex', fontSize: 68, lineHeight: 1.05 }}>
{item?.title ?? 'DFO Studio session'}
</div>
<div style={{ display: 'flex', fontSize: 30, color: '#5e5e52' }}>
{(item?.description ?? '').slice(0, 120)}
</div>
<div style={{ display: 'flex', fontSize: 30, color: '#5e5e52' }}>
{(item?.description ?? '').slice(0, 120)}
</div>
</div>
),
</div>,
size,
);
};
Expand Down
7 changes: 4 additions & 3 deletions src/app/(studio)/insights/gallery/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Suspense } from 'react';
import { GALLERY_ITEMS, galleryItem } from '@/components/organisms/Gallery/galleryData';
import Studio from '@/components/organisms/Studio/Studio';
import { gallerySession } from '@/lib/gallery/sessions';
import { pageOpenGraph } from '@/lib/site';

type GalleryItemPageProps = { params: Promise<{ slug: string }> };

Expand All @@ -26,11 +27,11 @@ export const generateMetadata = async ({ params }: GalleryItemPageProps): Promis
title: `${item.title} — DFO Gallery`,
description: item.description,
robots: { index: true, follow: true },
openGraph: {
type: 'article',
openGraph: pageOpenGraph({
title: item.title,
description: item.description,
},
image: null,
}),
};
};

Expand Down
3 changes: 1 addition & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Script from 'next/script';

import GoogleAnalytics from '@/components/atoms/GoogleAnalytics/GoogleAnalytics';
import { EMBED_PREFIX } from '@/lib/embed';
import { SITE_ORIGIN } from '@/lib/site';
import { SITE_NAME, SITE_ORIGIN } from '@/lib/site';

import '@fontsource/outfit/300.css';
import '@fontsource/outfit/400.css';
Expand All @@ -16,7 +16,6 @@ import '@fontsource/dm-mono/500.css';

import '@/styles/main.scss';

const SITE_NAME = 'Development Finance Observatory';
const SITE_DESCRIPTION =
'The Development Finance Observatory integrates aid, debt, climate and health finance, domestic budgets, and remittances into a single open platform, where every number is traceable to its source.';

Expand Down
8 changes: 5 additions & 3 deletions src/app/opengraph-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { join } from 'node:path';

import { ImageResponse } from 'next/og';

export const alt = 'Development Finance Observatory: Development Finance Data. Simplified.';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
import { CARD_SIZE, CARD_TYPE, SITE_CARD } from '@/lib/site';

export const alt = SITE_CARD.alt;
export const size = CARD_SIZE;
export const contentType = CARD_TYPE;

// Outfit, the site's typeface, from the @fontsource package the layout
// already loads. The image has no request-time input, so it renders once at
Expand Down
9 changes: 7 additions & 2 deletions src/components/organisms/DataSearch/SearchSentence.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import {
} from '@/lib/search/measureMenu';
import {
entityChoiceGroups,
entityChoiceLabels,
entityChoicePart,
offersAmbiguousPicker,
resolveTypedEdit,
Expand Down Expand Up @@ -1901,6 +1902,8 @@ export const SearchSentenceHeading = ({
}

if (!wordingParts.has(partIndex)) {
const labels = entityChoiceLabels(groups);

return withMarker(
<SlotMenu
key={index}
Expand All @@ -1909,7 +1912,7 @@ export const SearchSentenceHeading = ({
ariaLabel={slotAriaLabel('Match', token.text)}
marked
>
{groups.flatMap((group) =>
{groups.flatMap((group, groupIndex) =>
group.options.map((option, optionIndex) => (
<DropdownMenu.Item
key={`${group.key}:${optionIndex}`}
Expand All @@ -1918,7 +1921,9 @@ export const SearchSentenceHeading = ({
>
{/* Two ambiguous wordings share one
menu, each row naming its own. */}
<OptionRow detail={groups.length > 1 ? group.text : null}>
<OptionRow
detail={groups.length > 1 ? labels[groupIndex] : null}
>
{option.label}
</OptionRow>
</DropdownMenu.Item>
Expand Down
20 changes: 13 additions & 7 deletions src/components/organisms/Studio/Studio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import { logRefusedDescriptions } from '@/lib/studio/infoPanel';
import { GUEST_PROMPTS, promptEvent, type PromptIntent } from '@/lib/studio/promptAllowance';
import { takeSeeds } from '@/lib/studio/seedStore';
import { deriveTheme, themeVars } from '@/lib/studio/theme';
import { isPrompt } from '@/lib/studio/transcript';
import { isPrompt, isRefundedPrompt } from '@/lib/studio/transcript';
import type { Seed } from '@/lib/studio/wire';
import { pushConversation } from '@/lib/chat/historyClient';
import { uuid } from '@/lib/uuid';
Expand Down Expand Up @@ -165,20 +165,26 @@ const Studio = ({
const guestUsed = useSyncExternalStore(subscribeGuest, readGuestUsed, () => 0);
// A prompt counts once it is actually sent (a queued one can still be
// removed): the count follows the user turns in the transcript, so the
// queue's own drain is covered too. Only growth counts — New Chat empties
// the transcript without giving prompts back.
// queue's own drain is covered too. A prompt the harness refused before
// its turn started comes back, as the server's meter gives it back. Only
// growth counts, of either: New Chat empties the transcript without
// giving prompts back.
const sentCount = conversation.messages.filter(isPrompt).length;
const refundedCount = conversation.messages.filter(isRefundedPrompt).length;
const sentRef = useRef(sentCount);
const refundedRef = useRef(refundedCount);

useEffect(() => {
const delta = sentCount - sentRef.current;
const spent = Math.max(0, sentCount - sentRef.current);
const refunded = Math.max(0, refundedCount - refundedRef.current);

sentRef.current = sentCount;
refundedRef.current = refundedCount;

if (guest && delta > 0) {
writeGuestUsed(readGuestUsed() + delta);
if (guest && spent !== refunded) {
writeGuestUsed(Math.max(0, readGuestUsed() + spent - refunded));
}
}, [guest, sentCount]);
}, [guest, sentCount, refundedCount]);

// Queued prompts hold their place in the limit until sent or removed.
const walled = guest && guestUsed + conversation.queued.length >= GUEST_PROMPTS;
Expand Down
42 changes: 29 additions & 13 deletions src/components/organisms/Studio/hooks/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ import {
type Pause,
type PlanState,
} from '@/lib/studio/plan';
import { withAnswer } from '@/lib/studio/transcript';
import { withAnswer, withRefundedPrompt } from '@/lib/studio/transcript';
import {
cancelTurn,
fetchComponentRevision,
Expand Down Expand Up @@ -88,12 +88,14 @@ import type { CardRef, Widget } from '@/lib/chat/widgets';
// so the transcript shows what the question was about. `answer` marks the
// user's answer to a paused turn (a clarify choice or text, a plan
// amendment): shown like any user message, but part of the turn it answers.
// `refunded` marks a prompt whose turn was refused, which the site's prompt
// meter gave back.
// A bot turn's `followups` are prompts a click sends as the next message,
// and its `chips` switch a scope choice of the answer's readings. `plan` is
// the plan the user approved for this answer, saved with the transcript so
// a reopened conversation can show it.
export type ChatMessage =
| { role: 'user'; text: string; card?: { title: string }; answer?: true }
| { role: 'user'; text: string; card?: { title: string }; answer?: true; refunded?: true }
| {
role: 'bot';
text: string;
Expand Down Expand Up @@ -412,7 +414,9 @@ export const useChat = (initial?: ChatSnapshot) => {
// on screen. A caller without one gets `endOwn`. A caller with one (a
// switch, whose harness may re-offer the plan after a Stop) also keeps
// the frames after Stop, for up to SWITCH_STOP_GRACE_MS, with the chat
// line on "Stopping".
// line on "Stopping". `metered` marks a POST turn, whose prompt the
// site's meter gives back when the response is a refusal (meterTurn), so
// a refusal marks the turn's prompt refunded.
const stream = useCallback(
(
turn: number,
Expand All @@ -421,10 +425,12 @@ export const useChat = (initial?: ChatSnapshot) => {
onRefusal,
onUnreachable,
onEnd,
metered = false,
}: {
onRefusal?: (refusal: StreamRefusal) => boolean;
onUnreachable?: () => boolean;
onEnd?: (ending: StreamEnding) => void;
metered?: boolean;
} = {},
) => {
const controller = new AbortController();
Expand Down Expand Up @@ -848,11 +854,17 @@ export const useChat = (initial?: ChatSnapshot) => {
// handle() drops frames. A switch the user stopped then ends as
// stopped (its `onEnd`), not on a live card whose buttons the
// cancel has made useless.
const refuse = (refusal: StreamRefusal): boolean =>
generationRef.current !== generation ||
muted ||
stopped ||
(onRefusal?.(refusal) ?? false);
const refuse = (refusal: StreamRefusal): boolean => {
if (generationRef.current !== generation) {
return true;
}

if (metered) {
setState((prev) => ({ ...prev, messages: withRefundedPrompt(prev.messages) }));
}

return muted || stopped || (onRefusal?.(refusal) ?? false);
};
const unreachable = (): boolean =>
generationRef.current !== generation ||
muted ||
Expand Down Expand Up @@ -1012,11 +1024,14 @@ export const useChat = (initial?: ChatSnapshot) => {

// A question handed over from search carries its seeds, the
// datasets the user saw there, so the harness starts from them.
stream(turn, (handlers, signal) =>
runTurn(conversation, recap ? `${recap}${text}` : text, handlers, signal, {
...(componentRef && { component_ref: componentRef }),
...(seeds && { seed_spec: seeds }),
}),
stream(
turn,
(handlers, signal) =>
runTurn(conversation, recap ? `${recap}${text}` : text, handlers, signal, {
...(componentRef && { component_ref: componentRef }),
...(seeds && { seed_spec: seeds }),
}),
{ metered: true },
);
},
[holdTurn, startTurn, stream],
Expand Down Expand Up @@ -1318,6 +1333,7 @@ export const useChat = (initial?: ChatSnapshot) => {
turn,
(handlers, signal) => runApplyChoice(conversation, chip, handlers, signal),
{
metered: true,
onRefusal: (refusal) => {
const detail = chipRefusalDetail(refusal);

Expand Down
11 changes: 11 additions & 0 deletions src/lib/bot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { isbot } from 'isbot';

/** A crawler, link-preview fetcher or script, by its user agent. A request
* with no user agent comes from a script, so it counts as a bot. */
export const isBotAgent = (userAgent: string | null | undefined): boolean =>
!userAgent || isbot(userAgent);

/** Whether an event's request came from a bot. An event with no request
* behind it (null headers) is the server's own, never a bot's. */
export const isBotRequest = (headers: Headers | null): boolean =>
headers !== null && isBotAgent(headers.get('user-agent'));
Loading
Loading