chore: update dependencies

This commit is contained in:
Amruth Pillai
2026-07-08 18:54:00 +02:00
parent f9eafdf5cf
commit af5c7dde82
114 changed files with 7209 additions and 2056 deletions
+2 -2
View File
@@ -28,7 +28,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260705.1",
"typescript": "^6.0.3"
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"typescript": "^7.0.2"
}
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { AI_PROVIDER_DEFAULT_BASE_URLS, aiProviderSchema } from "./types";
const popularProviderDefaults = {
mistral: "https://api.mistral.ai/v1",
cohere: "https://api.cohere.com/v2",
xai: "https://api.x.ai/v1",
groq: "https://api.groq.com/openai/v1",
deepseek: "https://api.deepseek.com/v1",
togetherai: "https://api.together.xyz/v1",
fireworks: "https://api.fireworks.ai/inference/v1",
cerebras: "https://api.cerebras.ai/v1",
perplexity: "https://api.perplexity.ai",
} as const;
describe("AI provider types", () => {
it("accepts popular AI SDK text providers with default base URLs", () => {
for (const [provider, baseURL] of Object.entries(popularProviderDefaults)) {
expect(aiProviderSchema.parse(provider)).toBe(provider);
expect(AI_PROVIDER_DEFAULT_BASE_URLS[provider as keyof typeof AI_PROVIDER_DEFAULT_BASE_URLS]).toBe(baseURL);
}
});
});
+18
View File
@@ -6,6 +6,15 @@ const AI_PROVIDERS = [
"gemini",
"vercel-ai-gateway",
"openrouter",
"mistral",
"cohere",
"xai",
"groq",
"deepseek",
"togetherai",
"fireworks",
"cerebras",
"perplexity",
"ollama",
"openai-compatible",
] as const;
@@ -20,6 +29,15 @@ export const AI_PROVIDER_DEFAULT_BASE_URLS: Record<AIProvider, string> = {
gemini: "https://generativelanguage.googleapis.com/v1beta",
"vercel-ai-gateway": "https://ai-gateway.vercel.sh/v3/ai",
openrouter: "https://openrouter.ai/api/v1",
mistral: "https://api.mistral.ai/v1",
cohere: "https://api.cohere.com/v2",
xai: "https://api.x.ai/v1",
groq: "https://api.groq.com/openai/v1",
deepseek: "https://api.deepseek.com/v1",
togetherai: "https://api.together.xyz/v1",
fireworks: "https://api.fireworks.ai/inference/v1",
cerebras: "https://api.cerebras.ai/v1",
perplexity: "https://api.perplexity.ai",
ollama: "https://ollama.com/api",
"openai-compatible": "",
};
+21 -12
View File
@@ -18,14 +18,23 @@
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"dependencies": {
"@ai-sdk/anthropic": "^4.0.8",
"@ai-sdk/google": "^4.0.8",
"@ai-sdk/openai": "^4.0.7",
"@ai-sdk/openai-compatible": "^3.0.5",
"@aws-sdk/client-s3": "^3.1079.0",
"@orpc/client": "^1.14.6",
"@orpc/experimental-ratelimit": "^1.14.6",
"@orpc/server": "^1.14.6",
"@ai-sdk/anthropic": "^4.0.10",
"@ai-sdk/cerebras": "^3.0.6",
"@ai-sdk/cohere": "^4.0.6",
"@ai-sdk/deepseek": "^3.0.6",
"@ai-sdk/fireworks": "^3.0.7",
"@ai-sdk/google": "^4.0.10",
"@ai-sdk/groq": "^4.0.6",
"@ai-sdk/mistral": "^4.0.7",
"@ai-sdk/openai": "^4.0.9",
"@ai-sdk/openai-compatible": "^3.0.6",
"@ai-sdk/perplexity": "^4.0.7",
"@ai-sdk/togetherai": "^3.0.7",
"@ai-sdk/xai": "^4.0.9",
"@aws-sdk/client-s3": "^3.1081.0",
"@orpc/client": "^1.14.7",
"@orpc/experimental-ratelimit": "^1.14.7",
"@orpc/server": "^1.14.7",
"@reactive-resume/ai": "workspace:*",
"@reactive-resume/auth": "workspace:*",
"@reactive-resume/db": "workspace:*",
@@ -34,14 +43,14 @@
"@reactive-resume/resume": "workspace:*",
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"ai": "^7.0.15",
"ai": "^7.0.18",
"bcrypt": "^6.0.0",
"better-auth": "1.6.23",
"drizzle-orm": "1.0.0-rc.4",
"drizzle-zod": "1.0.0-beta.14-a36c63d",
"es-toolkit": "^1.49.0",
"ioredis": "^5.11.1",
"ollama-ai-provider-v2": "^3.6.0",
"ollama-ai-provider-v2": "^4.0.1",
"react": "^19.2.7",
"resumable-stream": "^2.2.12",
"sharp": "^0.35.3",
@@ -51,7 +60,7 @@
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/bcrypt": "^6.0.0",
"@typescript/native-preview": "7.0.0-dev.20260705.1",
"typescript": "^6.0.3"
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"typescript": "^7.0.2"
}
}
@@ -0,0 +1,17 @@
import z from "zod";
import { aiProviderSchema } from "@reactive-resume/ai/types";
export const providerInput = z.object({
label: z.string().trim().min(1),
provider: aiProviderSchema,
model: z.string().trim().min(1),
baseURL: z.string().trim().optional(),
apiKey: z.string().trim().min(1),
});
export const updateProviderInput = providerInput
.partial()
.extend({ id: z.string(), enabled: z.boolean().optional() })
.refine((input) => Object.keys(input).some((key) => key !== "id"), {
message: "At least one field must be provided.",
});
@@ -0,0 +1,8 @@
import { describe, expect, it } from "vitest";
import { updateProviderInput } from "./inputs";
describe("AI provider router input", () => {
it("does not default baseURL on switch-only updates", () => {
expect(updateProviderInput.parse({ id: "provider-1", enabled: false })).not.toHaveProperty("baseURL");
});
});
@@ -2,26 +2,11 @@ import type { AiProviderResponse } from "./service";
import { ORPCError } from "@orpc/client";
import { type } from "@orpc/server";
import z from "zod";
import { aiProviderSchema } from "@reactive-resume/ai/types";
import { protectedProcedure } from "../../context";
import { aiRequestRateLimit } from "../../middleware/rate-limit";
import { providerInput, updateProviderInput } from "./inputs";
import { aiProvidersService } from "./service";
const providerInput = z.object({
label: z.string().trim().min(1),
provider: aiProviderSchema,
model: z.string().trim().min(1),
baseURL: z.string().trim().optional().default(""),
apiKey: z.string().trim().min(1),
});
const updateProviderInput = providerInput
.partial()
.extend({ id: z.string(), enabled: z.boolean().optional() })
.refine((input) => Object.keys(input).some((key) => key !== "id"), {
message: "At least one field must be provided.",
});
function isAgentEnvironmentUnavailable(error: unknown) {
return error instanceof Error && error.message === "AGENT_ENVIRONMENT_UNAVAILABLE";
}
@@ -85,7 +70,7 @@ export const aiProvidersRouter = {
label: input.label,
provider: input.provider,
model: input.model,
baseURL: input.baseURL,
...(input.baseURL !== undefined ? { baseURL: input.baseURL } : {}),
apiKey: input.apiKey,
});
} catch (error) {
@@ -0,0 +1,122 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { dbMock, queryMock, queryState } = vi.hoisted(() => {
const state = {
rows: [] as unknown[],
whereArg: undefined as unknown,
orderByArgs: [] as unknown[],
};
const query = {
from: vi.fn(() => query),
where: vi.fn((arg: unknown) => {
state.whereArg = arg;
return query;
}),
orderBy: vi.fn((...args: unknown[]) => {
state.orderByArgs = args;
return query;
}),
limit: vi.fn(async () => state.rows),
};
return {
dbMock: { select: vi.fn(() => query) },
queryMock: query,
queryState: state,
};
});
vi.mock("@reactive-resume/db/client", () => ({ db: dbMock }));
vi.mock("@reactive-resume/db/schema", () => ({
aiProvider: {
id: "ai_provider.id",
userId: "ai_provider.user_id",
label: "ai_provider.label",
provider: "ai_provider.provider",
model: "ai_provider.model",
baseUrl: "ai_provider.base_url",
encryptedApiKey: "ai_provider.encrypted_api_key",
apiKeySalt: "ai_provider.api_key_salt",
apiKeyHash: "ai_provider.api_key_hash",
apiKeyPreview: "ai_provider.api_key_preview",
testStatus: "ai_provider.test_status",
testError: "ai_provider.test_error",
lastTestedAt: "ai_provider.last_tested_at",
lastUsedAt: "ai_provider.last_used_at",
enabled: "ai_provider.enabled",
createdAt: "ai_provider.created_at",
updatedAt: "ai_provider.updated_at",
},
}));
vi.mock("drizzle-orm", () => ({
and: (...conditions: unknown[]) => ({ type: "and", conditions }),
asc: (value: unknown) => ({ type: "asc", value }),
desc: (value: unknown) => ({ type: "desc", value }),
eq: (left: unknown, right: unknown) => ({ type: "eq", left, right }),
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ type: "sql", strings: [...strings], values }),
}));
vi.mock("../ai/credentials", () => ({
assertCredentialEncryptionConfigured: vi.fn(),
decryptCredential: vi.fn(() => "decrypted-key"),
encryptCredential: vi.fn(),
redactEncryptedCredential: vi.fn(() => ({
apiKeyFingerprint: "fingerprint",
apiKeyPreview: "sk-...test",
})),
}));
vi.mock("../ai/service", () => ({ testConnection: vi.fn() }));
vi.mock("../ai/url-policy", () => ({ resolveAiBaseUrl: vi.fn() }));
const { aiProvidersService } = await import("./service");
function providerRow(overrides: Record<string, unknown> = {}) {
return {
id: "provider-1",
userId: "user-1",
label: "OpenAI",
provider: "openai",
model: "gpt-5-mini",
baseUrl: null,
encryptedApiKey: "encrypted-key",
apiKeySalt: "salt",
apiKeyHash: "hash",
apiKeyPreview: "preview",
testStatus: "success",
testError: null,
lastTestedAt: new Date("2026-07-01T00:00:00Z"),
lastUsedAt: new Date("2026-07-07T00:00:00Z"),
enabled: true,
createdAt: new Date("2026-07-01T00:00:00Z"),
updatedAt: new Date("2026-07-01T00:00:00Z"),
...overrides,
};
}
describe("aiProvidersService", () => {
beforeEach(() => {
vi.clearAllMocks();
queryState.rows = [];
queryState.whereArg = undefined;
queryState.orderByArgs = [];
});
it("gets the first enabled and tested provider by creation order", async () => {
queryState.rows = [providerRow({ id: "first-created" })];
await expect(aiProvidersService.getDefaultRunnable({ userId: "user-1" })).resolves.toMatchObject({
id: "first-created",
apiKey: "decrypted-key",
});
expect(queryState.whereArg).toEqual({
type: "and",
conditions: [
{ type: "eq", left: "ai_provider.user_id", right: "user-1" },
{ type: "eq", left: "ai_provider.enabled", right: true },
{ type: "eq", left: "ai_provider.test_status", right: "success" },
],
});
expect(queryState.orderByArgs).toEqual([{ type: "asc", value: "ai_provider.created_at" }]);
expect(queryMock.limit).toHaveBeenCalledWith(1);
});
});
@@ -143,7 +143,7 @@ export const aiProvidersService = {
eq(schema.aiProvider.testStatus, "success"),
),
)
.orderBy(orderByLastUsedAtDescNullsLast(), asc(schema.aiProvider.createdAt))
.orderBy(asc(schema.aiProvider.createdAt))
.limit(1);
return provider
@@ -0,0 +1,110 @@
import { Buffer } from "node:buffer";
import { deflateRawSync } from "node:zlib";
import { afterEach, describe, expect, it, vi } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
const generateTextMock = vi.hoisted(() => vi.fn());
const envMock = vi.hoisted(() => ({
FLAG_ALLOW_UNSAFE_AI_BASE_URL: false,
}));
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
vi.mock("ai", async (importOriginal) => {
const actual = await importOriginal<typeof import("ai")>();
return { ...actual, generateText: generateTextMock };
});
afterEach(() => {
generateTextMock.mockReset();
});
const u16 = (value: number) => {
const buffer = Buffer.alloc(2);
buffer.writeUInt16LE(value);
return buffer;
};
const u32 = (value: number) => {
const buffer = Buffer.alloc(4);
buffer.writeUInt32LE(value);
return buffer;
};
function createDocxBase64(text: string): string {
const name = Buffer.from("word/document.xml");
const data = Buffer.from(
`<?xml version="1.0" encoding="UTF-8"?><w:document><w:body><w:p><w:r><w:t>${text}</w:t></w:r></w:p></w:body></w:document>`,
);
const compressed = deflateRawSync(data);
const local = Buffer.concat([
u32(0x04034b50),
u16(20),
u16(0),
u16(8),
u16(0),
u16(0),
u32(0),
u32(compressed.length),
u32(data.length),
u16(name.length),
u16(0),
name,
compressed,
]);
const central = Buffer.concat([
u32(0x02014b50),
u16(20),
u16(20),
u16(0),
u16(8),
u16(0),
u16(0),
u32(0),
u32(compressed.length),
u32(data.length),
u16(name.length),
u16(0),
u16(0),
u16(0),
u16(0),
u32(0),
u32(0),
name,
]);
const eocd = Buffer.concat([
u32(0x06054b50),
u16(0),
u16(0),
u16(1),
u16(1),
u32(central.length),
u32(local.length),
u16(0),
]);
return Buffer.concat([local, central, eocd]).toString("base64");
}
const { aiService } = await import("./service");
describe("AI DOCX parsing", () => {
it("sends DOCX content as extracted text instead of an unsupported file part", async () => {
generateTextMock.mockResolvedValue({ text: JSON.stringify(defaultResumeData) });
await aiService.parseDocx({
provider: "openai-compatible",
model: "test-model",
apiKey: "test-key",
baseURL: "https://example.test/v1",
mediaType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
file: { name: "resume.docx", data: createDocxBase64("Jane Doe &amp; Co") },
});
const request = generateTextMock.mock.calls[0]?.[0] as { messages: unknown[] };
const messages = JSON.stringify(request.messages);
expect(messages).toContain("Jane Doe & Co");
expect(messages).toContain("converted to plain text");
expect(messages).not.toContain('"type":"file"');
});
});
+75 -1
View File
@@ -1,8 +1,82 @@
import type { UIMessage } from "ai";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { convertToModelMessages, modelMessageSchema } from "ai";
const envMock = vi.hoisted(() => ({
FLAG_ALLOW_UNSAFE_AI_BASE_URL: false,
}));
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
afterEach(() => {
vi.unstubAllGlobals();
});
function stubOpenAICompatibleResponse(response?: { content?: string; finishReason?: string }) {
let requestBody: unknown;
const fetchMock = vi.fn(async (_input: unknown, init?: { body?: unknown }) => {
const body = JSON.parse(String(init?.body ?? "{}")) as { max_tokens?: number };
requestBody = body;
const hasEnoughOutputTokens = (body.max_tokens ?? 0) >= 128;
return new Response(
JSON.stringify({
id: "chatcmpl-test",
object: "chat.completion",
created: 1,
model: "test-model",
choices: [
{
index: 0,
message: { role: "assistant", content: response?.content ?? (hasEnoughOutputTokens ? "1" : "") },
finish_reason: response?.finishReason ?? (hasEnoughOutputTokens ? "stop" : "length"),
},
],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}),
{ headers: { "Content-Type": "application/json" } },
);
});
vi.stubGlobal("fetch", fetchMock);
return { fetchMock, getRequestBody: () => requestBody };
}
const { testConnection } = await import("./service");
describe("AI chat service", () => {
it("tests OpenAI-compatible providers without requiring structured output", async () => {
const openAiCompatible = stubOpenAICompatibleResponse();
await expect(
testConnection({
provider: "openai-compatible",
model: "test-model",
apiKey: "test-key",
baseURL: "https://example.test/v1",
}),
).resolves.toBe(true);
expect(openAiCompatible.fetchMock).toHaveBeenCalledTimes(1);
expect(openAiCompatible.getRequestBody()).not.toHaveProperty("response_format");
expect(openAiCompatible.getRequestBody()).toMatchObject({ max_tokens: 128, temperature: 0 });
});
it("explains when the provider test hits the output limit", async () => {
stubOpenAICompatibleResponse({ content: "1. The connection works.", finishReason: "length" });
await expect(
testConnection({
provider: "openai-compatible",
model: "test-model",
apiKey: "test-key",
baseURL: "https://example.test/v1",
}),
).rejects.toThrow("The model returned too much text during the provider test.");
});
it("keeps proposal tool history valid for follow-up chat messages", async () => {
const messages: UIMessage[] = [
{
+153 -9
View File
@@ -2,12 +2,22 @@ import type { AIProvider } from "@reactive-resume/ai/types";
import type { ResumeAnalysis } from "@reactive-resume/schema/resume/analysis";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { ModelMessage, UIMessage } from "ai";
import { inflateRawSync } from "node:zlib";
import { createAnthropic } from "@ai-sdk/anthropic";
import { createCerebras } from "@ai-sdk/cerebras";
import { createCohere } from "@ai-sdk/cohere";
import { createDeepSeek } from "@ai-sdk/deepseek";
import { createFireworks } from "@ai-sdk/fireworks";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createGroq } from "@ai-sdk/groq";
import { createMistral } from "@ai-sdk/mistral";
import { createOpenAI } from "@ai-sdk/openai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { createPerplexity } from "@ai-sdk/perplexity";
import { createTogetherAI } from "@ai-sdk/togetherai";
import { createXai } from "@ai-sdk/xai";
import { streamToEventIterator } from "@orpc/server";
import { convertToModelMessages, createGateway, generateText, Output, stepCountIs, streamText, tool } from "ai";
import { convertToModelMessages, createGateway, generateText, stepCountIs, streamText, tool } from "ai";
import { createOllama } from "ollama-ai-provider-v2";
import { match } from "ts-pattern";
import { z } from "zod";
@@ -72,6 +82,13 @@ type GetModelInput = {
const MAX_AI_FILE_BYTES = 10 * 1024 * 1024; // 10MB
const MAX_AI_FILE_BASE64_CHARS = Math.ceil((MAX_AI_FILE_BYTES * 4) / 3) + 4;
const TEST_CONNECTION_MAX_OUTPUT_TOKENS = 128;
const DOCX_DOCUMENT_XML_PATH = "word/document.xml";
const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
const ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
const ZIP_STORED_METHOD = 0;
const ZIP_DEFLATED_METHOD = 8;
export function getModel(input: GetModelInput) {
const { provider, model, apiKey } = input;
@@ -83,6 +100,15 @@ export function getModel(input: GetModelInput) {
.with("gemini", () => createGoogleGenerativeAI({ apiKey, baseURL }).languageModel(model))
.with("vercel-ai-gateway", () => createGateway({ apiKey, baseURL }).languageModel(model))
.with("openrouter", () => createOpenAICompatible({ name: "openrouter", apiKey, baseURL }).languageModel(model))
.with("mistral", () => createMistral({ apiKey, baseURL }).languageModel(model))
.with("cohere", () => createCohere({ apiKey, baseURL }).languageModel(model))
.with("xai", () => createXai({ apiKey, baseURL }).languageModel(model))
.with("groq", () => createGroq({ apiKey, baseURL }).languageModel(model))
.with("deepseek", () => createDeepSeek({ apiKey, baseURL }).languageModel(model))
.with("togetherai", () => createTogetherAI({ apiKey, baseURL }).languageModel(model))
.with("fireworks", () => createFireworks({ apiKey, baseURL }).languageModel(model))
.with("cerebras", () => createCerebras({ apiKey, baseURL }).languageModel(model))
.with("perplexity", () => createPerplexity({ apiKey, baseURL }).languageModel(model))
.with("openai-compatible", () =>
createOpenAICompatible({ name: "openai-compatible", apiKey, baseURL }).languageModel(model),
)
@@ -123,11 +149,15 @@ export async function testConnection(input: TestConnectionInput): Promise<boolea
const result = await generateText({
model: getModel(input),
output: Output.choice({ options: [RESPONSE_OK] }),
messages: [{ role: "user", content: `Respond only with JSON Object: { "result": "${RESPONSE_OK}" }` }],
maxOutputTokens: TEST_CONNECTION_MAX_OUTPUT_TOKENS,
temperature: 0,
messages: [{ role: "user", content: `Respond only with the single character: ${RESPONSE_OK}` }],
});
return result.output === RESPONSE_OK;
if (result.text.trim() === RESPONSE_OK) return true;
if (result.finishReason === "length") throw new Error("The model returned too much text during the provider test.");
return false;
}
type ParsePdfInput = z.infer<typeof aiCredentialsSchema> & {
@@ -156,6 +186,20 @@ function buildResumeParsingMessages({ userPrompt, file, mediaType }: BuildResume
];
}
function buildResumeParsingTextMessages({ userPrompt, text }: { userPrompt: string; text: string }): ModelMessage[] {
return [
{
role: "user",
content: [
{
type: "text",
text: `${userPrompt}\n\nThe Microsoft Word file has been converted to plain text below.\n\n${text}`,
},
],
},
];
}
async function parsePdf(input: ParsePdfInput): Promise<ResumeData> {
const model = getModel(input);
@@ -177,17 +221,117 @@ type ParseDocxInput = z.infer<typeof aiCredentialsSchema> & {
mediaType: "application/msword" | "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
};
function assertZipRange(buffer: Buffer, offset: number, length: number) {
if (offset < 0 || length < 0 || offset + length > buffer.length) throw new Error("Invalid DOCX archive.");
}
function findEndOfCentralDirectory(buffer: Buffer): number {
const minOffset = Math.max(0, buffer.length - 0xffff - 22);
for (let offset = buffer.length - 22; offset >= minOffset; offset--) {
if (buffer.readUInt32LE(offset) === ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) return offset;
}
throw new Error("Invalid DOCX archive.");
}
function readZipEntry(buffer: Buffer, entryName: string): Buffer {
const eocdOffset = findEndOfCentralDirectory(buffer);
assertZipRange(buffer, eocdOffset, 22);
const centralDirectorySize = buffer.readUInt32LE(eocdOffset + 12);
const centralDirectoryOffset = buffer.readUInt32LE(eocdOffset + 16);
assertZipRange(buffer, centralDirectoryOffset, centralDirectorySize);
let offset = centralDirectoryOffset;
const endOffset = centralDirectoryOffset + centralDirectorySize;
while (offset < endOffset) {
assertZipRange(buffer, offset, 46);
if (buffer.readUInt32LE(offset) !== ZIP_CENTRAL_DIRECTORY_SIGNATURE) throw new Error("Invalid DOCX archive.");
const compressionMethod = buffer.readUInt16LE(offset + 10);
const compressedSize = buffer.readUInt32LE(offset + 20);
const fileNameLength = buffer.readUInt16LE(offset + 28);
const extraFieldLength = buffer.readUInt16LE(offset + 30);
const commentLength = buffer.readUInt16LE(offset + 32);
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
const fileNameOffset = offset + 46;
assertZipRange(buffer, fileNameOffset, fileNameLength);
const fileName = buffer.toString("utf8", fileNameOffset, fileNameOffset + fileNameLength);
if (fileName === entryName) {
assertZipRange(buffer, localHeaderOffset, 30);
if (buffer.readUInt32LE(localHeaderOffset) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) {
throw new Error("Invalid DOCX archive.");
}
const localFileNameLength = buffer.readUInt16LE(localHeaderOffset + 26);
const localExtraFieldLength = buffer.readUInt16LE(localHeaderOffset + 28);
const dataOffset = localHeaderOffset + 30 + localFileNameLength + localExtraFieldLength;
assertZipRange(buffer, dataOffset, compressedSize);
const compressed = buffer.subarray(dataOffset, dataOffset + compressedSize);
if (compressionMethod === ZIP_STORED_METHOD) return compressed;
if (compressionMethod === ZIP_DEFLATED_METHOD) return inflateRawSync(compressed);
throw new Error("Unsupported DOCX archive compression.");
}
offset = fileNameOffset + fileNameLength + extraFieldLength + commentLength;
}
throw new Error("DOCX document content not found.");
}
function decodeXmlEntities(value: string): string {
return value.replace(/&(#x[\da-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (entity, token: string) => {
if (token === "amp") return "&";
if (token === "lt") return "<";
if (token === "gt") return ">";
if (token === "quot") return '"';
if (token === "apos") return "'";
if (token.toLowerCase().startsWith("#x")) return String.fromCodePoint(Number.parseInt(token.slice(2), 16));
if (token.startsWith("#")) return String.fromCodePoint(Number.parseInt(token.slice(1), 10));
return entity;
});
}
function extractDocxText(file: z.infer<typeof fileInputSchema>): string {
const documentXml = readZipEntry(Buffer.from(file.data, "base64"), DOCX_DOCUMENT_XML_PATH).toString("utf8");
// ponytail: minimal OOXML body-text extraction; add a DOCX parser dependency if tracked changes matter.
const text = decodeXmlEntities(
documentXml
.replace(/<w:tab\b[^>]*\/>/g, "\t")
.replace(/<w:br\b[^>]*\/>/g, "\n")
.replace(/<\/w:p>/g, "\n")
.replace(/<[^>]+>/g, ""),
)
.replace(/\r/g, "")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
if (!text) throw new Error("DOCX document content is empty.");
return text;
}
async function parseDocx(input: ParseDocxInput): Promise<ResumeData> {
const model = getModel(input);
const messages =
input.mediaType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
? buildResumeParsingTextMessages({ userPrompt: docxParserUserPrompt, text: extractDocxText(input.file) })
: buildResumeParsingMessages({
userPrompt: docxParserUserPrompt,
file: input.file,
mediaType: input.mediaType,
});
const result = await generateText({
model,
system: buildResumeParsingSystemPrompt(docxParserSystemPrompt),
messages: buildResumeParsingMessages({
userPrompt: docxParserUserPrompt,
file: input.file,
mediaType: input.mediaType,
}),
messages,
}).catch((error: unknown) => logAndRethrow("Failed to generate the text with the model", error));
return parseAndValidateResumeJson(result.text);
+3 -3
View File
@@ -17,7 +17,7 @@
"dependencies": {
"@better-auth/api-key": "^1.6.23",
"@better-auth/drizzle-adapter": "^1.6.23",
"@better-auth/infra": "^0.3.4",
"@better-auth/infra": "^0.3.5",
"@better-auth/oauth-provider": "^1.6.23",
"@better-auth/passkey": "^1.6.23",
"@reactive-resume/db": "workspace:*",
@@ -34,7 +34,7 @@
"@reactive-resume/config": "workspace:*",
"@types/bcrypt": "^6.0.0",
"@types/react": "^19.2.17",
"@typescript/native-preview": "7.0.0-dev.20260705.1",
"typescript": "^6.0.3"
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"typescript": "^7.0.2"
}
}
+2 -2
View File
@@ -31,8 +31,8 @@
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/pg": "^8.20.0",
"@typescript/native-preview": "7.0.0-dev.20260705.1",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"drizzle-kit": "1.0.0-rc.4",
"typescript": "^6.0.3"
"typescript": "^7.0.2"
}
}
+2 -2
View File
@@ -20,7 +20,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260705.1",
"typescript": "^6.0.3"
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"typescript": "^7.0.2"
}
}
+4 -4
View File
@@ -19,14 +19,14 @@
"@reactive-resume/env": "workspace:*",
"nodemailer": "^9.0.3",
"react": "^19.2.7",
"react-email": "^6.6.6"
"react-email": "^6.6.8"
},
"devDependencies": {
"@react-email/ui": "^6.6.6",
"@react-email/ui": "^6.6.8",
"@reactive-resume/config": "workspace:*",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19.2.17",
"@typescript/native-preview": "7.0.0-dev.20260705.1",
"typescript": "^6.0.3"
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"typescript": "^7.0.2"
}
}
+3 -3
View File
@@ -20,8 +20,8 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/node": "^26.1.0",
"@typescript/native-preview": "7.0.0-dev.20260705.1",
"typescript": "^6.0.3"
"@types/node": "^26.1.1",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"typescript": "^7.0.2"
}
}
+2 -2
View File
@@ -19,7 +19,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260705.1",
"typescript": "^6.0.3"
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"typescript": "^7.0.2"
}
}
+2 -2
View File
@@ -23,7 +23,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260705.1",
"typescript": "^6.0.3"
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"typescript": "^7.0.2"
}
}
+4 -4
View File
@@ -20,7 +20,7 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@orpc/server": "^1.14.6",
"@orpc/server": "^1.14.7",
"@reactive-resume/ai": "workspace:*",
"@reactive-resume/api": "workspace:*",
"@reactive-resume/env": "workspace:*",
@@ -29,8 +29,8 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260705.1",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"typescript": "^7.0.2",
"vitest": "^4.1.10"
}
}
+3 -3
View File
@@ -25,7 +25,7 @@
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"cjk-regex": "^3.4.0",
"node-html-parser": "^8.0.4",
"node-html-parser": "^9.0.0",
"phosphor-icons-react-pdf": "^0.1.3",
"react": "^19.2.7",
"react-pdf-html": "^2.1.5"
@@ -34,7 +34,7 @@
"@react-pdf/types": "^2.11.1",
"@reactive-resume/config": "workspace:*",
"@types/react": "^19.2.17",
"@typescript/native-preview": "7.0.0-dev.20260705.1",
"typescript": "^6.0.3"
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"typescript": "^7.0.2"
}
}
+9 -1
View File
@@ -1,5 +1,6 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import type { ResumeRenderOptions } from "./context";
import type { SectionTitleResolver } from "./section-title";
import { createElement } from "react";
import { pdf } from "#react-pdf-renderer";
@@ -8,13 +9,20 @@ import { ResumeDocument } from "./document";
type CreateResumePdfBlobOptions = {
data: ResumeData;
template?: Template | undefined;
renderOptions?: ResumeRenderOptions | undefined;
resolveSectionTitle?: SectionTitleResolver | undefined;
};
export const createResumePdfBlob = async ({ data, template, resolveSectionTitle }: CreateResumePdfBlobOptions) => {
export const createResumePdfBlob = async ({
data,
template,
renderOptions,
resolveSectionTitle,
}: CreateResumePdfBlobOptions) => {
const document = createElement(ResumeDocument, {
data,
template: template ?? data.metadata.template,
...(renderOptions ? { renderOptions } : {}),
resolveSectionTitle,
}) as Parameters<typeof pdf>[0];
+15 -3
View File
@@ -4,24 +4,36 @@ import type { SectionTitleResolver } from "./section-title";
import { createContext, use, useMemo } from "react";
import { isRTL } from "@reactive-resume/utils/locale";
export type ResumeRenderOptions = {
includeCoverLetterHeader?: boolean;
};
type RenderContextValue = ResumeData & {
resolveSectionTitle?: SectionTitleResolver | undefined;
renderOptions: ResumeRenderOptions;
rtl: boolean;
};
const RenderContext = createContext<RenderContextValue | null>(null);
const defaultRenderOptions: ResumeRenderOptions = {};
type RenderProviderProps = {
data: ResumeData;
resolveSectionTitle?: SectionTitleResolver | undefined;
renderOptions?: ResumeRenderOptions | undefined;
children: ReactNode;
};
export const RenderProvider = ({ data, resolveSectionTitle, children }: RenderProviderProps) => {
export const RenderProvider = ({
data,
resolveSectionTitle,
renderOptions = defaultRenderOptions,
children,
}: RenderProviderProps) => {
const rtl = isRTL(data.metadata.page.locale);
const contextValue = useMemo<RenderContextValue>(
() => ({ ...data, resolveSectionTitle, rtl }),
[data, resolveSectionTitle, rtl],
() => ({ ...data, resolveSectionTitle, renderOptions, rtl }),
[data, resolveSectionTitle, renderOptions, rtl],
);
return <RenderContext.Provider value={contextValue}>{children}</RenderContext.Provider>;
+4 -2
View File
@@ -2,6 +2,7 @@ import type { LayoutPage, ResumeData, Typography } from "@reactive-resume/schema
import type { Template } from "@reactive-resume/schema/templates";
import type { Locale } from "@reactive-resume/utils/locale";
import type { ComponentType } from "react";
import type { ResumeRenderOptions } from "./context";
import type { SectionTitleResolver } from "./section-title";
import { useMemo } from "react";
import { Document } from "#react-pdf-renderer";
@@ -19,13 +20,14 @@ export type TemplatePage = ComponentType<TemplatePageProps>;
type ResumeDocumentProps = {
data: ResumeData;
template: Template;
renderOptions?: ResumeRenderOptions | undefined;
resolveSectionTitle?: SectionTitleResolver | undefined;
};
const getLayoutPageKey = (page: LayoutPage, pageIndex: number) =>
`${page.fullWidth ? "full" : "split"}:${page.main.join(",")}:${page.sidebar.join(",")}:${pageIndex}`;
export const ResumeDocument = ({ data, template, resolveSectionTitle }: ResumeDocumentProps) => {
export const ResumeDocument = ({ data, template, renderOptions, resolveSectionTitle }: ResumeDocumentProps) => {
const TemplatePageComponent = getTemplatePage(template);
const creationDate = useMemo(() => new Date(), []);
const hasCjkContent = useMemo(() => resumeContentContainsCJK(data), [data]);
@@ -43,7 +45,7 @@ export const ResumeDocument = ({ data, template, resolveSectionTitle }: ResumeDo
const resumeData = useMemo(() => ({ ...data, metadata: { ...data.metadata, typography } }), [data, typography]);
return (
<RenderProvider data={resumeData} resolveSectionTitle={resolveSectionTitle}>
<RenderProvider data={resumeData} resolveSectionTitle={resolveSectionTitle} renderOptions={renderOptions}>
<Document
pageMode="useNone"
creationDate={creationDate}
@@ -26,6 +26,13 @@ describe("shouldShowResumeHeader", () => {
expect(shouldShowResumeHeader(createCoverLetterOnlyData(), 0)).toBe(false);
});
it("can keep the first-page header for cover letter documents", () => {
const data = { ...createCoverLetterOnlyData(), renderOptions: { includeCoverLetterHeader: true } };
expect(shouldShowResumeHeader(data, 0)).toBe(true);
expect(shouldShowResumeHeader(data, 1)).toBe(false);
});
it("keeps the first-page header for normal resume documents", () => {
expect(shouldShowResumeHeader(sampleResumeData, 0)).toBe(true);
expect(shouldShowResumeHeader(sampleResumeData, 1)).toBe(false);
@@ -1,6 +1,11 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { ResumeRenderOptions } from "../../context";
import { filterSections } from "./filtering";
type HeaderResumeData = ResumeData & {
renderOptions?: ResumeRenderOptions;
};
const isCoverLetterSection = (data: ResumeData, sectionId: string) =>
data.customSections.some((section) => section.id === sectionId && section.type === "cover-letter");
@@ -12,5 +17,5 @@ const isCoverLetterOnlyDocument = (data: ResumeData) => {
return visibleSections.length > 0 && visibleSections.every((sectionId) => isCoverLetterSection(data, sectionId));
};
export const shouldShowResumeHeader = (data: ResumeData, pageIndex: number) =>
pageIndex === 0 && !isCoverLetterOnlyDocument(data);
export const shouldShowResumeHeader = (data: HeaderResumeData, pageIndex: number) =>
pageIndex === 0 && (data.renderOptions?.includeCoverLetterHeader || !isCoverLetterOnlyDocument(data));
+2 -2
View File
@@ -23,7 +23,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260705.1",
"typescript": "^6.0.3"
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"typescript": "^7.0.2"
}
}
+2 -2
View File
@@ -22,7 +22,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260705.1",
"typescript": "^6.0.3"
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"typescript": "^7.0.2"
}
}
+3 -3
View File
@@ -23,7 +23,7 @@
"@phosphor-icons/react": "^2.1.10",
"@phosphor-icons/web": "^2.1.2",
"@reactive-resume/utils": "workspace:*",
"@shadcn/react": "^0.2.0",
"@shadcn/react": "^0.2.1",
"class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",
"next-themes": "^0.4.6",
@@ -40,9 +40,9 @@
"@tailwindcss/typography": "^0.5.20",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260705.1",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"postcss": "^8.5.16",
"tailwindcss": "^4.3.2",
"typescript": "^6.0.3"
"typescript": "^7.0.2"
}
}
+3 -3
View File
@@ -32,8 +32,8 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/node": "^26.1.0",
"@typescript/native-preview": "7.0.0-dev.20260705.1",
"typescript": "^6.0.3"
"@types/node": "^26.1.1",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"typescript": "^7.0.2"
}
}