Discover: add a "Minor AI usage" AI filter (#104)

This commit is contained in:
Jared Schoeny
2026-10-03 13:44:33 -06:00
committed by GitHub
parent 904206013a
commit 39897c4c37
4 changed files with 40 additions and 18 deletions

View File

@@ -33,7 +33,7 @@ import {
AI_AREAS_DISCLOSURE,
} from "./DiscoverFilters";
import { baseGameLabel, baseRoms } from "@/data/baseRoms";
import { aiFilterLabel, aiPresetOf, matchesAiFilter, NO_AI_FILTER, type AiFilter } from "@/utils/aiDisclosure";
import { aiFilterActive, aiFilterLabel, aiPresetOf, matchesAiFilter, NO_AI_FILTER, type AiFilter } from "@/utils/aiDisclosure";
const SORT_OPTIONS: SelectOption[] = [
{ value: "trending", label: "Trending", icon: MdWhatshot },
@@ -354,7 +354,7 @@ export default function DiscoverBrowser({ catalog, generatedAt, initialState, ta
const railRef = React.useRef<HTMLElement>(null);
// The AI note under the results: phones open the sheet at "AI use", desktop points at it in the rail.
const showAiFilter = () => (window.matchMedia("(min-width: 768px)").matches ? flashAiGroup(railRef.current) : openSheet(true));
const aiNote = ai.hide.length > 0 && (
const aiNote = aiFilterActive(ai) && (
<button type="button" onClick={showAiFilter} className="text-link-hd inline-flex items-center gap-1 font-normal">
<AiOffIcon className="h-3.5 w-3.5" />
{aiFilterLabel(ai)}
@@ -515,7 +515,7 @@ export default function DiscoverBrowser({ catalog, generatedAt, initialState, ta
</button>
)}
{/* Clear keeps the saved AI filter, so offer it here when it may be what's hiding everything. */}
{ai.hide.length > 0 && (
{aiFilterActive(ai) && (
<button type="button" className="text-link-hd text-sm" onClick={showAiFilter}>
Change AI filter
</button>

View File

@@ -9,7 +9,7 @@ import { baseGameLabel, baseRoms, PLATFORM_NAMES, type Platform } from "@/data/b
const RAIL_PLATFORMS: Platform[] = ["GBA", "GBC", "GB", "NDS"];
import { DISCOVER_COMPLETION_STATUSES } from "@/app/discover/search-params";
import type { DiscoverHack } from "@/types/discover";
import { AI_AREAS, AI_PRESETS, aiPresetOf, matchesAiFilter, normalizeAiFilter, NO_AI_FILTER, type AiArea, type AiFilter } from "@/utils/aiDisclosure";
import { AI_AREAS, AI_PRESETS, aiFilterActive, aiPresetOf, matchesAiFilter, normalizeAiFilter, NO_AI_FILTER, type AiArea, type AiFilter } from "@/utils/aiDisclosure";
import { useCloseOnDesktop, useDialog } from "@/hooks/useDialog";
/** The filterable part of the Discover URL state. The sheet edits a draft copy of this. */
@@ -61,7 +61,7 @@ export function countClearable(f: FilterState) {
/** Everything narrowing the results, for the phone's Filters badge. */
export function countActive(f: FilterState) {
return countClearable(f) + (f.ai.hide.length > 0 ? 1 : 0);
return countClearable(f) + (aiFilterActive(f.ai) ? 1 : 0);
}
export function toggleValue<T>(list: readonly T[], value: T) {
@@ -122,8 +122,9 @@ export function FilterFields({ value, onChange, tagGroups, counts, readyCount, o
const aiPreset = aiPresetOf(value.ai);
const setAi = (ai: AiFilter) => onChange({ ...value, ai: normalizeAiFilter(ai) });
// Ticking code yourself allows small use by default; only the presets are strict about it.
// Ticking an area leaves Minor, since it's a limit across all areas rather than a pick of them.
const toggleAiArea = (k: AiArea) =>
setAi({ hide: toggleValue(value.ai.hide, k), smallCode: k === "code" ? !value.ai.hide.includes("code") : value.ai.smallCode });
setAi({ hide: toggleValue(value.ai.hide, k), smallCode: k === "code" ? !value.ai.hide.includes("code") : value.ai.smallCode, minor: false });
const visibleGames = ROM_GAMES.map((g) => ({ ...g, dumps: g.dumps.filter((d) => !romQuery || g.label.toLowerCase().includes(romQuery) || d.label.toLowerCase().includes(romQuery)) })).filter((g) => g.dumps.length > 0);
const visibleGroups = tagGroups
@@ -240,13 +241,14 @@ export function FilterFields({ value, onChange, tagGroups, counts, readyCount, o
<Disclosure
id={AI_AREAS_DISCLOSURE}
label="Choose areas"
detail={value.ai.hide.length > 0 ? `${value.ai.hide.length} hidden` : ""}
detail={value.ai.minor ? "Limit on all areas" : value.ai.hide.length > 0 ? `${value.ai.hide.length} hidden` : ""}
expanded={open.has(AI_AREAS_DISCLOSURE)}
onToggle={() => onToggleOpen(AI_AREAS_DISCLOSURE)}
tall={tall}
>
{/* Indented to line up with the "Choose areas" label, past its chevron. */}
<div className="pl-[22px]">
{value.ai.minor && <p className="mb-2 text-xs leading-[1.4] text-text-2">Minor AI usage sets a limit across all areas. Pick areas to build your own filter instead.</p>}
<p className="mb-0.5 text-xs font-semibold text-text-3">Hide hacks with AI in</p>
{AI_AREAS.map((a) => (
<React.Fragment key={a.key}>

View File

@@ -1,4 +1,4 @@
import { aiFilterToken, NO_AI_FILTER, parseAiFilter, type AiFilter } from "@/utils/aiDisclosure";
import { aiFilterActive, aiFilterToken, NO_AI_FILTER, parseAiFilter, type AiFilter } from "@/utils/aiDisclosure";
const KEY = "hackdex:discover-ai";
@@ -16,7 +16,7 @@ export function readSavedAiFilter(): AiFilter {
/** "Any" clears it. */
export function saveAiFilter(filter: AiFilter) {
try {
if (filter.hide.length > 0) localStorage.setItem(KEY, aiFilterToken(filter));
if (aiFilterActive(filter)) localStorage.setItem(KEY, aiFilterToken(filter));
else localStorage.removeItem(KEY);
} catch {}
}

View File

@@ -72,35 +72,51 @@ export function aiKind(levels: AiLevels): AiKind {
/**
* Discover's AI filter: hide hacks whose label shows AI in any of `hide`. With `smallCode`, code at
* "A little" (a bug fix or a few) still passes. Hacks without a label pass every filter; most use no
* AI, and reports catch the rest.
* "A little" (a bug fix or a few) still passes. `minor` is a separate mode that ignores `hide`: AI in
* at most MINOR_MAX_AREAS areas, none above Some. Hacks without a label pass every filter; most use
* no AI, and reports catch the rest.
*/
export interface AiFilter {
hide: readonly AiArea[];
smallCode: boolean;
minor: boolean;
}
const MINOR_MAX_AREAS = 2;
const CONTENT_AREAS = AI_AREAS.map((a) => a.key).filter((k) => k !== "code");
/** Named filters, shown as radios; any other pick of areas is "Custom". */
export const AI_PRESETS = [
{ value: "any", label: "Any", filter: { hide: [], smallCode: false } },
{ value: "no-content", label: "No AI content", filter: { hide: CONTENT_AREAS, smallCode: false } },
{ value: "any", label: "Any", filter: { hide: [], smallCode: false, minor: false } },
{
value: "minor",
label: "Minor AI usage",
hint: `Up to ${MINOR_MAX_AREAS} areas, none above Some`,
filter: { hide: [], smallCode: false, minor: true },
},
{ value: "no-content", label: "No AI content", filter: { hide: CONTENT_AREAS, smallCode: false, minor: false } },
{
value: "none",
label: "No direct AI usage",
hint: "Nothing AI-generated was intentionally added by the creator",
filter: { hide: AI_AREAS.map((a) => a.key), smallCode: false },
filter: { hide: AI_AREAS.map((a) => a.key), smallCode: false, minor: false },
},
] as const satisfies readonly { value: string; label: string; hint?: string; filter: AiFilter }[];
export type AiPreset = (typeof AI_PRESETS)[number]["value"];
export const NO_AI_FILTER: AiFilter = AI_PRESETS[0].filter;
/** Areas in label order, and smallCode only while code is hidden, so equal filters serialize the same. */
/** Areas in label order, smallCode only while code is hidden, and no areas with minor, so equal filters serialize the same. */
export function normalizeAiFilter(f: AiFilter): AiFilter {
if (f.minor) return { hide: [], smallCode: false, minor: true };
const hide = AI_AREAS.map((a) => a.key).filter((k) => f.hide.includes(k));
return { hide, smallCode: f.smallCode && hide.includes("code") };
return { hide, smallCode: f.smallCode && hide.includes("code"), minor: false };
}
/** Whether the filter hides anything; "Any" doesn't. */
export function aiFilterActive(f: AiFilter) {
return f.minor || f.hide.length > 0;
}
/** Stored value (Discover saves the pick): a preset's name, or the areas joined by "." ("code-small" for code with small use allowed). */
@@ -112,7 +128,7 @@ export function aiFilterToken(f: AiFilter): string {
}
function sameAiFilter(a: AiFilter, b: AiFilter) {
return a.smallCode === b.smallCode && a.hide.length === b.hide.length && a.hide.every((k, i) => k === b.hide[i]);
return a.minor === b.minor && a.smallCode === b.smallCode && a.hide.length === b.hide.length && a.hide.every((k, i) => k === b.hide[i]);
}
export function parseAiFilter(token: string | undefined): AiFilter {
@@ -120,7 +136,7 @@ export function parseAiFilter(token: string | undefined): AiFilter {
if (preset || !token) return preset?.filter ?? NO_AI_FILTER;
const parts = token.split(".");
const hide = AI_AREAS.map((a) => a.key).filter((k) => parts.includes(k) || (k === "code" && parts.includes("code-small")));
return normalizeAiFilter({ hide, smallCode: parts.includes("code-small") });
return normalizeAiFilter({ hide, smallCode: parts.includes("code-small"), minor: false });
}
/** The preset a filter matches, or "custom". */
@@ -137,6 +153,10 @@ export function aiFilterLabel(f: AiFilter): string {
export function matchesAiFilter(levels: AiLevels | null, f: AiFilter) {
if (!levels) return true;
if (f.minor) {
const used = AI_AREAS.filter((a) => levels[a.key] !== "none");
return used.length <= MINOR_MAX_AREAS && used.every((a) => AI_LEVEL_STEP[levels[a.key]] <= AI_LEVEL_STEP.some);
}
return f.hide.every((k) => levels[k] === "none" || (k === "code" && f.smallCode && levels.code === "little"));
}