mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 13:12:44 +00:00
feat(docs): add OpenRouter AI provider and improved tag color algorithm
This commit is contained in:
1 parent
3b9079f87f
commit
1f6413f3d8
3 files changed
+112
-49
No files matched your search
@@ -1,4 +1,5 @@
|
|||||||
import { defineConfig } from "blume";
|
import { defineConfig } from "blume";
|
||||||
|
import { openrouter } from "blume/ai";
|
||||||
import type { StandardSchema } from "blume/core/standard-schema.ts";
|
import type { StandardSchema } from "blume/core/standard-schema.ts";
|
||||||
import { custom, filesystem } from "blume/sources";
|
import { custom, filesystem } from "blume/sources";
|
||||||
import { githubReleasesSource } from "blume/sources/github-releases.ts";
|
import { githubReleasesSource } from "blume/sources/github-releases.ts";
|
||||||
@@ -134,6 +135,18 @@ export default defineConfig({
|
|||||||
structuredData: true,
|
structuredData: true,
|
||||||
x: { handle: "@prdlk" },
|
x: { handle: "@prdlk" },
|
||||||
},
|
},
|
||||||
|
// The assistant's `/api/ask` route runs on demand and reads
|
||||||
|
// OPENROUTER_API_KEY at request time (`getSecret`), so it needs a server
|
||||||
|
// runtime holding that secret — a static host cannot answer it.
|
||||||
|
ai: {
|
||||||
|
assistant: {
|
||||||
|
enabled: true,
|
||||||
|
provider: openrouter({
|
||||||
|
model: "qwen/qwen3.8-27b:free",
|
||||||
|
reasoning: "none",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
agents: {
|
agents: {
|
||||||
llmsTxt: true,
|
llmsTxt: true,
|
||||||
mcp: { enabled: false },
|
mcp: { enabled: false },
|
||||||
|
|||||||
+98
-49
@@ -3,78 +3,127 @@
|
|||||||
* row (`components/ProblemBadges.astro`) paints each tag with it, so a tag
|
* row (`components/ProblemBadges.astro`) paints each tag with it, so a tag
|
||||||
* reads the same on every page and no two tags share a colour.
|
* reads the same on every page and no two tags share a colour.
|
||||||
*
|
*
|
||||||
* Every colour sits at one OKLCH lightness, so each badge has the same
|
* Fifty-odd hues cannot all be told apart, so uniqueness alone is not the
|
||||||
* contrast whatever its hue; only the hue varies. A tag's hue is seeded by a
|
* goal: tags that share a problem page must be told apart, since that is
|
||||||
* hash of its slug (FNV-1a) and walked forward past any hue already taken
|
* where they are seen together. The palette is a fixed grid in OKLCH, a few
|
||||||
* within `gap` degrees, in slug order. Seeding by slug rather than by position
|
* lightness rows by `HUE_STEP` hues, and tags are coloured greedily, most-used
|
||||||
* keeps colours stable as tags come and go: a new tag moves only itself,
|
* first. Each tag takes the free palette colour farthest (OKLab distance)
|
||||||
* unless it lands on a hue a later slug's seed wanted.
|
* from every colour its co-tags already hold, ties broken by distance from
|
||||||
|
* every colour taken at all. The answer is a pure function of the pages'
|
||||||
|
* tags, so it is identical on every page of one build; a newly solved problem
|
||||||
|
* can shift colours in the next.
|
||||||
*
|
*
|
||||||
* Blume's Badge takes a custom colour only as hex, and uses that one value
|
* Blume's Badge takes a custom colour only as hex and uses that one value in
|
||||||
* for both themes, so the lightness is the midpoint that stays legible as text
|
* both themes, as text over a 12% tint of itself, so the rows sit in the
|
||||||
* on the light and the dark background alike.
|
* narrow lightness band where that text holds ≥ 3:1 contrast on the light and
|
||||||
|
* the dark background alike; wider rows spread tags further but fail one theme.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const LIGHTNESS = 0.6;
|
const LIGHTNESS = [0.55, 0.6];
|
||||||
const CHROMA = 0.15;
|
const CHROMA = 0.15;
|
||||||
|
const HUE_STEP = 5;
|
||||||
|
|
||||||
/** slug → `#rrggbb`, pairwise distinct, for every slug in `slugs`. */
|
interface Swatch {
|
||||||
export function tagColors(slugs: Iterable<string>): Map<string, string> {
|
hex: string;
|
||||||
const sorted = [...new Set(slugs)].sort();
|
lab: [number, number, number];
|
||||||
// Each taken hue blocks 2·gap−1 whole degrees, so n·(2·gap−1) < 360 always
|
|
||||||
// leaves a free one; past 180 tags the hues can only be distinct.
|
|
||||||
const gap = Math.max(1, Math.floor(360 / (2 * sorted.length)));
|
|
||||||
const taken: number[] = [];
|
|
||||||
const colors = new Map<string, string>();
|
|
||||||
const used = new Set<string>();
|
|
||||||
for (const slug of sorted) {
|
|
||||||
let hue = fnv1a(slug) % 360;
|
|
||||||
for (let step = 0; step < 360; step++, hue = (hue + 1) % 360) {
|
|
||||||
if (taken.some((t) => Math.min(Math.abs(t - hue), 360 - Math.abs(t - hue)) < gap)) continue;
|
|
||||||
if (!used.has(oklchHex(hue))) break;
|
|
||||||
}
|
|
||||||
taken.push(hue);
|
|
||||||
const hex = oklchHex(hue);
|
|
||||||
used.add(hex);
|
|
||||||
colors.set(slug, hex);
|
|
||||||
}
|
|
||||||
return colors;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fnv1a(text: string): number {
|
|
||||||
let hash = 0x811c9dc5;
|
|
||||||
for (let i = 0; i < text.length; i++) {
|
|
||||||
hash ^= text.charCodeAt(i);
|
|
||||||
hash = Math.imul(hash, 0x01000193);
|
|
||||||
}
|
|
||||||
return hash >>> 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `oklch(LIGHTNESS CHROMA hue)` as sRGB hex. Hues sRGB cannot reach at full
|
* slug → `#rrggbb`, pairwise distinct, for every slug in `slugs`; `pages` is
|
||||||
* chroma (cyans) give up chroma until they fit, rather than being clipped
|
* each problem page's tag slugs, the co-occurrence the spread is judged by.
|
||||||
* per channel, which would shift the hue.
|
|
||||||
*/
|
*/
|
||||||
function oklchHex(hue: number): string {
|
export function tagColors(slugs: Iterable<string>, pages: readonly (readonly string[])[]): Map<string, string> {
|
||||||
|
const universe = new Set(slugs);
|
||||||
|
const uses = new Map<string, number>();
|
||||||
|
const cotags = new Map<string, Set<string>>();
|
||||||
|
for (const page of pages) {
|
||||||
|
const on = page.filter((slug) => universe.has(slug));
|
||||||
|
for (const slug of on) {
|
||||||
|
uses.set(slug, (uses.get(slug) ?? 0) + 1);
|
||||||
|
const set = cotags.get(slug) ?? new Set<string>();
|
||||||
|
for (const other of on) if (other !== slug) set.add(other);
|
||||||
|
cotags.set(slug, set);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const order = [...universe].sort((a, b) => (uses.get(b) ?? 0) - (uses.get(a) ?? 0) || a.localeCompare(b));
|
||||||
|
|
||||||
|
const palette = swatches(order.length);
|
||||||
|
const free = new Set(palette.keys());
|
||||||
|
const chosen = new Map<string, Swatch>();
|
||||||
|
for (const slug of order) {
|
||||||
|
const near = [...(cotags.get(slug) ?? [])].flatMap((other) => chosen.get(other) ?? []);
|
||||||
|
const all = [...chosen.values()];
|
||||||
|
let best = -1;
|
||||||
|
let bestScore: [number, number] = [-1, -1];
|
||||||
|
for (const i of free) {
|
||||||
|
const score: [number, number] = [nearest(palette[i]!, near), nearest(palette[i]!, all)];
|
||||||
|
if (score[0] > bestScore[0] || (score[0] === bestScore[0] && score[1] > bestScore[1])) {
|
||||||
|
best = i;
|
||||||
|
bestScore = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
free.delete(best);
|
||||||
|
chosen.set(slug, palette[best]!);
|
||||||
|
}
|
||||||
|
return new Map([...chosen].map(([slug, swatch]) => [slug, swatch.hex]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distance from `swatch` to the closest of `others`; Infinity when there are none. */
|
||||||
|
function nearest(swatch: Swatch, others: readonly Swatch[]): number {
|
||||||
|
let min = Number.POSITIVE_INFINITY;
|
||||||
|
for (const other of others) {
|
||||||
|
const d = Math.hypot(swatch.lab[0] - other.lab[0], swatch.lab[1] - other.lab[1], swatch.lab[2] - other.lab[2]);
|
||||||
|
if (d < min) min = d;
|
||||||
|
}
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The palette: every `HUE_STEP` degrees in each `LIGHTNESS` row, the step
|
||||||
|
* shrunk when there are more tags than swatches. Duplicate hexes (possible
|
||||||
|
* only at a tiny step) are dropped, which keeps every assignment unique.
|
||||||
|
*/
|
||||||
|
function swatches(count: number): Swatch[] {
|
||||||
|
const step = Math.min(HUE_STEP, Math.max(1, Math.floor((360 * LIGHTNESS.length) / Math.max(count, 1))));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: Swatch[] = [];
|
||||||
|
for (const lightness of LIGHTNESS) {
|
||||||
|
for (let hue = 0; hue < 360; hue += step) {
|
||||||
|
const swatch = oklch(lightness, hue);
|
||||||
|
if (seen.has(swatch.hex)) continue;
|
||||||
|
seen.add(swatch.hex);
|
||||||
|
out.push(swatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `oklch(lightness CHROMA hue)` as sRGB hex plus its OKLab coordinates. Hues
|
||||||
|
* sRGB cannot reach at full chroma (cyans) give up chroma until they fit,
|
||||||
|
* rather than being clipped per channel, which would shift the hue.
|
||||||
|
*/
|
||||||
|
function oklch(lightness: number, hue: number): Swatch {
|
||||||
const rad = (hue * Math.PI) / 180;
|
const rad = (hue * Math.PI) / 180;
|
||||||
for (let chroma = CHROMA; ; chroma -= 0.005) {
|
for (let chroma = CHROMA; ; chroma -= 0.005) {
|
||||||
const a = chroma * Math.cos(rad);
|
const a = chroma * Math.cos(rad);
|
||||||
const b = chroma * Math.sin(rad);
|
const b = chroma * Math.sin(rad);
|
||||||
const l = (LIGHTNESS + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
||||||
const m = (LIGHTNESS - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
||||||
const s = (LIGHTNESS - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
||||||
const rgb = [
|
const rgb = [
|
||||||
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
||||||
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
||||||
-0.0041960863 * l - 0.7034186147 * m + 1.707614681 * s,
|
-0.0041960863 * l - 0.7034186147 * m + 1.707614681 * s,
|
||||||
];
|
];
|
||||||
if (chroma > 0 && rgb.some((c) => c < 0 || c > 1)) continue;
|
if (chroma > 0 && rgb.some((c) => c < 0 || c > 1)) continue;
|
||||||
return `#${rgb
|
const hex = rgb
|
||||||
.map((c) => {
|
.map((c) => {
|
||||||
const v = Math.min(1, Math.max(0, c));
|
const v = Math.min(1, Math.max(0, c));
|
||||||
const srgb = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
|
const srgb = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
|
||||||
return Math.round(srgb * 255).toString(16).padStart(2, "0");
|
return Math.round(srgb * 255).toString(16).padStart(2, "0");
|
||||||
})
|
})
|
||||||
.join("")}`;
|
.join("");
|
||||||
|
return { hex: `#${hex}`, lab: [lightness, a, b] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"doctor": "blume doctor"
|
"doctor": "blume doctor"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@openrouter/ai-sdk-provider": "^3.1.0",
|
||||||
"blume": "^2.1.0",
|
"blume": "^2.1.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
Reference in new issue
Block a user