Skip to content
Open
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ NEXT_PUBLIC_MAPTILER_STYLE_KEY=123

# SEO
PREVENT_SEARCH_BOTS=false

# AI
GEMINI_API_KEY=
3 changes: 2 additions & 1 deletion app/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ SENTRY_IGNORE_API_RESOLUTION_ERROR=1
NEXT_PUBLIC_VECTOR_TILE_URL=https://world.vectortiles.geo.admin.ch
NEXT_PUBLIC_MAPTILER_STYLE_KEY=123
ADFS_PROFILE_URL=https://www.myaccount-r.eiam.admin.ch/
NEXTAUTH_URL=https://localhost:3000
NEXTAUTH_URL=https://localhost:3000
GEMINI_API_KEY=123
171 changes: 171 additions & 0 deletions app/ai/use-chart-ai-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import {
extractChartConfigUsedComponents,
useQueryFilters,
} from "@/charts/shared/chart-helpers";
import { ChartConfig, ChartType } from "@/config-types";
import { getChartConfig } from "@/config-utils";
import {
hasChartConfigs,
useConfiguratorState,
} from "@/configurator/configurator-state";
import { ObservationValue } from "@/domain/data";
import {
useDataCubesComponentsQuery,
useDataCubesMetadataQuery,
useDataCubesObservationsQuery,
} from "@/graphql/hooks";
import { ComponentId } from "@/graphql/make-component-id";
import { DataCubeObservationFilter } from "@/graphql/query-hooks";
import { useLocale } from "@/locales/use-locale";

type Dimension = {
id: ComponentId;
label: string;
};

type Measure = {
id: ComponentId;
label: string;
};

type Observations = {
columns: string[];
values: ObservationValue[][];
rowCount: number;
truncated: boolean;
};

type DatasetMetadata = {
iri: string;
title: string;
description: string;
datePublished: string;
themes: string[];
};

export type ChartAIContext = {
chartType: ChartType;
datasets: DatasetMetadata[];
filters: DataCubeObservationFilter[];
fields: ChartConfig["fields"];
dimensions: Dimension[];
measures: Measure[];
observations: Observations;
};

export const useChartAIContext = (): ChartAIContext => {
const locale = useLocale();
const [state] = useConfiguratorState(hasChartConfigs);
const chartConfig = getChartConfig(state);
const commonQueryVariables = {
sourceType: state.dataSource.type,
sourceUrl: state.dataSource.url,
locale,
} as const;

const [{ data: metadataData }] = useDataCubesMetadataQuery({
variables: {
...commonQueryVariables,
cubeFilters: chartConfig.cubes.map((cube) => ({ iri: cube.iri })),
},
});

const [{ data: componentsData }] = useDataCubesComponentsQuery({
chartConfig,
variables: {
...commonQueryVariables,
cubeFilters: chartConfig.cubes.map((cube) => ({
iri: cube.iri,
joinBy: cube.joinBy,
})),
},
keepPreviousData: true,
});

const queryFilters = useQueryFilters({
chartConfig,
dashboardFilters: state.dashboardFilters,
});

const [{ data: observationsData }] = useDataCubesObservationsQuery({
chartConfig,
variables: {
...commonQueryVariables,
cubeFilters: queryFilters,
},
keepPreviousData: true,
});

const datasets = metadataData?.dataCubesMetadata ?? [];
const dimensions = componentsData?.dataCubesComponents.dimensions ?? [];
const measures = componentsData?.dataCubesComponents.measures ?? [];
const observations = observationsData?.dataCubesObservations?.data ?? [];

const dimensionSummaries: Dimension[] = dimensions.map((d) => {
return {
id: d.id,
label: d.label ?? "",
};
});

const measureSummaries: Measure[] = measures.map((m) => ({
id: m.id,
label: m.label ?? "",
}));

const maxRows = 200;
const rowCount = observations.length;
const indices = Array.from({ length: rowCount }, (_, i) => i);

for (let i = indices.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[indices[i], indices[j]] = [indices[j], indices[i]];
}
const sampledSet = new Set(indices.slice(0, Math.min(maxRows, rowCount)));
const truncated = rowCount > maxRows;
const rows = observations.filter((_, idx) => sampledSet.has(idx));

const usedDimensionIds = extractChartConfigUsedComponents(chartConfig, {
components: [...dimensions, ...measures],
}).map((d) => d.id);

const values = rows.map((row) => {
return usedDimensionIds.map((col) => {
const v = row[col];
return v === undefined ? null : (v as ObservationValue);
});
});

const compact: ChartAIContext = {
chartType: chartConfig.chartType,
datasets: datasets.map((m) => ({
iri: m.iri,
title: m.title,
description: m.description,
datePublished: m.datePublished ?? "",
themes: (m.themes ?? []).map((t) => t.label ?? ""),
})),
filters: queryFilters,
fields: chartConfig.fields,
dimensions: dimensionSummaries.filter((d) =>
usedDimensionIds.includes(d.id)
),
measures: measureSummaries.filter((m) => usedDimensionIds.includes(m.id)),
observations:
usedDimensionIds.length > 0
? {
columns: usedDimensionIds,
values,
rowCount,
truncated,
}
: {
columns: [],
values: [],
rowCount: 0,
truncated: false,
},
};

return compact;
};
56 changes: 56 additions & 0 deletions app/components/ai/generate-meta-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { t } from "@lingui/macro";
import { CircularProgress, IconButton, Tooltip } from "@mui/material";
import { useState } from "react";

import { useChartAIContext } from "@/ai/use-chart-ai-context";
import { Icon } from "@/icons";
import { Locale } from "@/locales/locales";
import { useEvent } from "@/utils/use-event";

export const GenerateMetaButton = ({
locale,
field,
onResult,
}: {
locale: Locale;
field: "title" | "description";
onResult: (value: string) => void;
}) => {
const [loading, setLoading] = useState(false);
const context = useChartAIContext();

const handleClick = useEvent(async () => {
try {
setLoading(true);
const response = await fetch("/api/ai/generate-meta", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ field, locale, context }),
});
const { data } = (await response.json()) as { data: { text: string } };

if (data.text) {
onResult(data.text);
}
} finally {
setLoading(false);
}
});

const label =
field === "title"
? t({ id: "ai.generate.title", message: "Generate title" })
: t({ id: "ai.generate.description", message: "Generate description" });

return (
<Tooltip title={label} placement="top">
<IconButton size="small" onClick={handleClick} disabled={loading}>
{loading ? (
<CircularProgress size={20} />
) : (
<Icon name="star" size={20} />
)}
</IconButton>
</Tooltip>
);
};
78 changes: 53 additions & 25 deletions app/components/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
RefObject,
SyntheticEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
Expand Down Expand Up @@ -583,6 +584,7 @@ export const MarkdownInput = ({
disablePlugins,
disableToolbar,
characterLimit,
toolbarEndSlot,
}: {
label?: string | ReactNode;
characterLimit?: number;
Expand All @@ -595,10 +597,21 @@ export const MarkdownInput = ({
listToggles?: boolean;
link?: boolean;
};
toolbarEndSlot?: ReactNode;
} & FieldProps) => {
const classes = useMarkdownInputStyles();
const [characterLimitReached, setCharacterLimitReached] = useState(false);
const { headings: disableHeadings } = disablePlugins ?? {};
const [markdown, setMarkdown] = useState(value ? `${value}` : "");
const [editorKey, setEditorKey] = useState(0);

useEffect(() => {
const incoming = value ? `${value}` : "";
if (incoming !== markdown) {
setMarkdown(incoming);
setEditorKey((k) => k + 1);
}
}, [markdown, value]);

const handleMaxLengthReached = useEvent(
({ reachedMaxLength }: { reachedMaxLength: boolean }) => {
Expand All @@ -609,33 +622,44 @@ export const MarkdownInput = ({
return (
<div>
<MDXEditor
key={editorKey}
className={clsx(
classes.root,
disableHeadings && classes.withoutHeadings
)}
markdown={value ? `${value}` : ""}
markdown={markdown}
plugins={[
toolbarPlugin({
toolbarClassName: classes.toolbar,
toolbarContents: () => (
<div>
<Flex gap={2}>
{disableToolbar?.textStyles ? null : (
<BoldItalicUnderlineToggles />
)}
{disableToolbar?.blockType ? null : <BlockTypeMenu />}
{disableToolbar?.listToggles ? null : (
<>
<Divider flexItem orientation="vertical" />
<ListToggles />
</>
)}
{disableToolbar?.link ? null : (
<>
<Divider flexItem orientation="vertical" />
<LinkDialogToggle />
</>
)}
<div style={{ width: "100%" }}>
<Flex
sx={{
alignItems: "center",
justifyContent: "space-between",
gap: 2,
width: "100%",
}}
>
<Flex gap={2}>
{disableToolbar?.textStyles ? null : (
<BoldItalicUnderlineToggles />
)}
{disableToolbar?.blockType ? null : <BlockTypeMenu />}
{disableToolbar?.listToggles ? null : (
<>
<Divider flexItem orientation="vertical" />
<ListToggles />
</>
)}
{disableToolbar?.link ? null : (
<>
<Divider flexItem orientation="vertical" />
<LinkDialogToggle />
</>
)}
</Flex>
{toolbarEndSlot}
</Flex>
{label && name ? <Label htmlFor={name}>{label}</Label> : null}
</div>
Expand All @@ -654,14 +678,18 @@ export const MarkdownInput = ({
}),
]}
onChange={(newValue) => {
const v = newValue
// Remove backslashes from the string, as they are not supported in react-markdown
.replaceAll("\\", "")
// <u> is not supported in react-markdown we use for rendering.
.replaceAll("<u>", "<ins>")
.replace("</u>", "</ins>")
.replaceAll("\\", "");

setMarkdown(v);
onChange?.({
currentTarget: {
value: newValue
// Remove backslashes from the string, as they are not supported in react-markdown
.replaceAll("\\", "")
// <u> is not supported in react-markdown we use for rendering.
.replaceAll("<u>", "<ins>")
.replace("</u>", "</ins>"),
value: v,
},
} as any);
}}
Expand Down
Loading
Loading