feat: support editing AI provider models in the UI and auto-fill LinkedIn job postings (#3259)

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
Emanuele Tonello
2026-07-27 13:21:57 +02:00
committed by GitHub
co-authored by Amruth Pillai
parent 3266066826
commit 47349e7ab3
4 changed files with 288 additions and 3 deletions
@@ -41,6 +41,8 @@ const queryClient = vi.hoisted(() => ({
setQueryData: vi.fn(),
}));
const providers = vi.hoisted(() => ({ data: [] as MockProvider[] }));
const mutations = vi.hoisted(() => ({
create: vi.fn(),
test: vi.fn(),
@@ -56,7 +58,7 @@ const mutationOptions = vi.hoisted(() => ({
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: [], isLoading: false, error: null }),
useQuery: () => ({ data: providers.data, isLoading: false, error: null }),
useQueryClient: () => queryClient,
useMutation: (options: MutationOptions) => ({
isPending: false,
@@ -140,6 +142,7 @@ describe("AISettingsSection", () => {
mutations.test.mockReset();
mutations.update.mockReset();
mutations.delete.mockReset();
providers.data = [];
});
it("offers popular AI SDK providers and labels Ollama as cloud-hosted", () => {
@@ -187,4 +190,18 @@ describe("AISettingsSection", () => {
];
expect(updater([created])).toEqual([tested]);
});
it("updates a configured provider's model", async () => {
providers.data = [provider({})];
mutations.update.mockResolvedValue(provider({ model: "gpt-5-mini", testStatus: "untested", enabled: false }));
renderSection();
fireEvent.click(screen.getByRole("button", { name: "Edit model" }));
fireEvent.change(screen.getByLabelText("Provider model"), { target: { value: "gpt-5-mini" } });
fireEvent.click(screen.getByRole("button", { name: "Save model" }));
await waitFor(() => expect(mutations.update).toHaveBeenCalledWith({ id: "provider-1", model: "gpt-5-mini" }));
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: ["aiProviders", "list"] });
});
});
@@ -4,7 +4,15 @@ import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ORPCError } from "@orpc/client";
import { CheckCircleIcon, KeyIcon, PlusIcon, TrashIcon, WarningCircleIcon, XCircleIcon } from "@phosphor-icons/react";
import {
CheckCircleIcon,
KeyIcon,
PencilIcon,
PlusIcon,
TrashIcon,
WarningCircleIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { toast } from "sonner";
@@ -202,11 +210,31 @@ function isAiProviderConfigError(error: unknown) {
function ProviderRow({ provider }: ProviderRowProps) {
const queryClient = useQueryClient();
const [isEditingModel, setIsEditingModel] = useState(false);
const [model, setModel] = useState(provider.model);
const invalidate = () => queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.queryKey() });
const { mutate: testProvider, isPending: isTesting } = useMutation(orpc.aiProviders.test.mutationOptions());
const { mutate: updateProvider, isPending: isUpdating } = useMutation(orpc.aiProviders.update.mutationOptions());
const { mutate: deleteProvider, isPending: isDeleting } = useMutation(orpc.aiProviders.delete.mutationOptions());
const isMutating = isTesting || isUpdating || isDeleting;
const saveModel = () => {
const nextModel = model.trim();
if (!nextModel || nextModel === provider.model) {
setIsEditingModel(false);
return;
}
updateProvider(
{ id: provider.id, model: nextModel },
{
onSuccess: () => {
setIsEditingModel(false);
void invalidate();
},
onError: (error) => toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to update provider.` })),
},
);
};
return (
<div className="grid gap-4 rounded-md border bg-card p-4 md:grid-cols-[1fr_auto]">
@@ -223,8 +251,29 @@ function ProviderRow({ provider }: ProviderRowProps) {
<div className="grid gap-1 text-muted-foreground text-sm">
<p>
{providerLabel(provider.provider)} · {provider.model}
{providerLabel(provider.provider)}
{isEditingModel ? "" : ` · ${provider.model}`}
</p>
{isEditingModel ? (
<div className="flex max-w-md gap-2">
<Input
aria-label={t`Provider model`}
value={model}
disabled={isMutating}
onChange={(event) => setModel(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") saveModel();
if (event.key === "Escape") setIsEditingModel(false);
}}
/>
<Button size="sm" disabled={!model.trim() || isMutating} onClick={saveModel}>
<Trans>Save model</Trans>
</Button>
<Button size="sm" variant="ghost" disabled={isMutating} onClick={() => setIsEditingModel(false)}>
<Trans>Cancel</Trans>
</Button>
</div>
) : null}
<p className="truncate">{provider.baseURL ?? AI_PROVIDER_DEFAULT_BASE_URLS[provider.provider]}</p>
<p>
<Trans>Key</Trans>: {provider.apiKeyPreview}
@@ -281,6 +330,21 @@ function ProviderRow({ provider }: ProviderRowProps) {
<Trans>Test</Trans>
</Button>
<Button
size="icon"
variant="ghost"
disabled={isMutating}
onClick={() => {
setModel(provider.model);
setIsEditingModel(true);
}}
>
<PencilIcon />
<span className="sr-only">
<Trans>Edit model</Trans>
</span>
</Button>
<Button
size="icon"
variant="ghost"
@@ -125,6 +125,38 @@ describe("fetchJobPostingText", () => {
await expect(fetchJobPostingText("https://jobs.example/posting")).resolves.toBe("Senior Engineer");
expect(lookupResult).toEqual([{ address: "93.184.216.34", family: 4 }]);
});
it("reads LinkedIn job URLs through the public guest endpoint", async () => {
requestMock.mockImplementation((url, _options, callback) => {
expect(String(url)).toBe("https://www.linkedin.com/jobs-guest/jobs/api/jobPosting/4426311357");
const response = Readable.from([
Buffer.from(`
<h1 class="top-card-layout__title">Senior &amp;lt;Engineer&amp;gt;</h1>
<a class="topcard__org-name-link">Example &amp; Co</a>
<span class="topcard__flavor topcard__flavor--bullet">Remote</span>
<div class="show-more-less-html__markup"><p>Build useful products.</p><p>Work with TypeScript.</p></div>
<h3 class="description__job-criteria-subheader">Employment type</h3>
<span class="description__job-criteria-text">Full-time</span>
`),
]) as Readable & { statusCode: number; headers: Record<string, string> };
response.statusCode = 200;
response.headers = { "content-type": "text/html" };
callback(response);
return { on: vi.fn(), end: vi.fn() };
});
const posting = await fetchJobPostingText("https://www.linkedin.com/jobs/view/senior-engineer-4426311357");
expect(posting).toContain("Company: Example & Co");
expect(posting).toContain("Senior &lt;Engineer&gt;");
expect(posting).toContain("Build useful products.");
});
it("rejects LinkedIn URLs without a job posting ID", async () => {
await expect(fetchJobPostingText("https://www.linkedin.com/jobs/search/?keywords=engineer")).rejects.toMatchObject({
code: "BAD_REQUEST",
});
expect(requestMock).not.toHaveBeenCalled();
});
});
describe("autofillInputSchema", () => {
@@ -18,8 +18,21 @@ const reserved = { tags: ["Applications", "AI"] } as const;
const MAX_JOB_POSTING_BYTES = 200_000;
const MAX_PASTED_JOB_DESCRIPTION_CHARS = 20_000;
const JOB_POSTING_CONTENT_TYPES = ["text/html", "text/plain", "application/xhtml+xml", "application/xml", "text/xml"];
const LINKEDIN_JOB_POSTING_URL = "https://www.linkedin.com/jobs-guest/jobs/api/jobPosting";
const LINKEDIN_FETCH_RETRIES = 3;
type ValidatedAddress = { address: string; family: 4 | 6 };
type LinkedInJobPosting = {
title: string;
company: string | null;
location: string | null;
description: string | null;
seniority: string | null;
employmentType: string | null;
jobFunction: string | null;
industries: string | null;
};
// Resolve the user's default (tested + enabled) AI provider into a ready model instance.
async function resolveModel(userId: string) {
const provider = await aiProvidersService.getDefaultRunnable({ userId });
@@ -179,8 +192,167 @@ function requestJobPosting(parsed: URL, address: ValidatedAddress, signal: Abort
});
}
function decodeLinkedInHtml(text: string) {
const numericEntity = (value: string, radix: number) => {
const codePoint = Number.parseInt(value, radix);
return codePoint >= 0 && codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : "";
};
return text
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/&#(\d+);/g, (_match, decimal: string) => numericEntity(decimal, 10))
.replace(/&#[xX]([0-9a-fA-F]+);/g, (_match, hexadecimal: string) => numericEntity(hexadecimal, 16))
.replace(/&amp;/g, "&");
}
function cleanLinkedInHtml(html: string) {
return decodeLinkedInHtml(
html
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim(),
);
}
function linkedInJobId(url: string) {
try {
const parsed = new URL(url);
const hostname = parsed.hostname.toLowerCase();
if (hostname !== "linkedin.com" && !hostname.endsWith(".linkedin.com")) return null;
const slug = parsed.pathname.split("/").filter(Boolean).at(-1) ?? "";
return slug.match(/(?:^|-)(\d{6,})$/)?.[1] ?? null;
} catch {
return null;
}
}
function isLinkedInUrl(url: string) {
try {
const hostname = new URL(url).hostname.toLowerCase();
return hostname === "linkedin.com" || hostname.endsWith(".linkedin.com");
} catch {
return false;
}
}
function parseLinkedInJobPosting(html: string): LinkedInJobPosting {
const title = html.match(/class="(?:top-card-layout__title|topcard__title)[^"]*"[^>]*>([\s\S]*?)<\/h[12]>/i)?.[1];
const organization = html.match(/class="topcard__org-name-link[^"]*"[^>]*>([\s\S]*?)<\/a>/i)?.[1];
const location = html.match(/class="topcard__flavor topcard__flavor--bullet"[^>]*>([\s\S]*?)<\/span>/i)?.[1];
const description = html.match(
/class="(?:show-more-less-html__markup|description__text[^"]*)"[^>]*>([\s\S]*?)<\/div>/i,
)?.[1];
const criteria: Record<string, string> = {};
const criteriaPattern =
/class="description__job-criteria-subheader"[^>]*>([\s\S]*?)<\/h3>[\s\S]*?class="description__job-criteria-text[^"]*"[^>]*>([\s\S]*?)<\/span>/gi;
for (const match of html.matchAll(criteriaPattern)) {
const [label, value] = [match[1], match[2]];
if (!label || !value) continue;
criteria[cleanLinkedInHtml(label).toLowerCase()] = cleanLinkedInHtml(value);
}
return {
title: title ? cleanLinkedInHtml(title) : "",
company: organization ? cleanLinkedInHtml(organization) || null : null,
location: location ? cleanLinkedInHtml(location) || null : null,
description: description
? decodeLinkedInHtml(
description
.replace(/<\s*br\s*\/?>/gi, "\n")
.replace(/<\/(p|li|ul|ol|div|h\d)>/gi, "\n")
.replace(/<[^>]+>/g, " "),
)
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim() || null
: null,
seniority: criteria["seniority level"] ?? null,
employmentType: criteria["employment type"] ?? null,
jobFunction: criteria["job function"] ?? null,
industries: criteria.industries ?? null,
};
}
function linkedInPostingText(posting: LinkedInJobPosting) {
return [
posting.title,
posting.company ? `Company: ${posting.company}` : "",
posting.location ? `Location: ${posting.location}` : "",
posting.seniority ? `Seniority: ${posting.seniority}` : "",
posting.employmentType ? `Employment type: ${posting.employmentType}` : "",
posting.jobFunction ? `Job function: ${posting.jobFunction}` : "",
posting.industries ? `Industries: ${posting.industries}` : "",
posting.description ?? "",
]
.filter(Boolean)
.join("\n\n")
.slice(0, 8_000);
}
async function fetchLinkedInJobPostingText(jobId: string): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
for (let attempt = 0; attempt <= LINKEDIN_FETCH_RETRIES; attempt++) {
const response = await new Promise<IncomingMessage>((resolve, reject) => {
const request = https.request(
new URL(`${LINKEDIN_JOB_POSTING_URL}/${jobId}`),
{
signal: controller.signal,
headers: {
"user-agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"accept-language": "en-US,en;q=0.9",
"x-requested-with": "XMLHttpRequest",
},
},
resolve,
);
request.on("error", reject);
request.end();
});
if (response.statusCode === 429 || (response.statusCode && response.statusCode >= 500)) {
response.resume();
if (attempt === LINKEDIN_FETCH_RETRIES) {
throw new ORPCError("BAD_REQUEST", { message: "LinkedIn is temporarily unavailable. Try again later." });
}
await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** attempt));
continue;
}
if (response.statusCode === 404) {
throw new ORPCError("BAD_REQUEST", { message: "The LinkedIn job posting was not found." });
}
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
throw new ORPCError("BAD_REQUEST", { message: "Couldn't fetch the LinkedIn job posting." });
}
const posting = parseLinkedInJobPosting(await readTextResponse(response));
const text = linkedInPostingText(posting);
if (!text) throw new ORPCError("BAD_REQUEST", { message: "Couldn't read the LinkedIn job posting." });
return text;
}
throw new ORPCError("BAD_REQUEST", { message: "LinkedIn is temporarily unavailable. Try again later." });
} finally {
clearTimeout(timeout);
}
}
// Best-effort fetch + strip of a job posting page. http(s) only, size/time capped.
export async function fetchJobPostingText(url: string): Promise<string> {
const jobId = linkedInJobId(url);
if (jobId) return await fetchLinkedInJobPostingText(jobId);
if (isLinkedInUrl(url)) {
throw new ORPCError("BAD_REQUEST", { message: "The LinkedIn job URL must include a job posting ID." });
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {