first commit
Some checks failed
Some checks failed
This commit is contained in:
469
app/lib/testcase-backend/generator.ts
Normal file
469
app/lib/testcase-backend/generator.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import os from "os";
|
||||
import { executeStandardCode } from "./runner";
|
||||
import { createZipFile } from "./zip";
|
||||
|
||||
const OUTPUT_BASE = path.join(os.tmpdir(), "i-tools-testcase");
|
||||
|
||||
interface Constraint {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
function parseConstraint(text: string, variable: string): Constraint | null {
|
||||
const escapedVariable = variable.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const patterns = [
|
||||
new RegExp(`(-?\\d+(?:\\.\\d+)?)\\s*[≤<=]\\s*${escapedVariable}\\s*[≤<=]\\s*(-?\\d+(?:\\.\\d+)?)`, "i"),
|
||||
new RegExp(`(-?\\d+)\\s*[≤<=]\\s*${escapedVariable}\\s*[≤<=]\\s*(-?\\d+)\\s*\\^\\s*(-?\\d+)`, "i"),
|
||||
new RegExp(`(-?[\\d,]+)\\s*[≤<=]\\s*${escapedVariable}\\s*[≤<=]\\s*(-?[\\d,]+)`, "i"),
|
||||
new RegExp(`${escapedVariable}\\s*[∈∊]\\s*\\[\\s*(-?\\d+(?:\\.\\d+)?)\\s*,\\s*(-?\\d+(?:\\.\\d+)?)\\s*\\]`, "i"),
|
||||
new RegExp(`${escapedVariable}.*?(?:范围|取值).*?(-?\\d+(?:\\.\\d+)?).*?(?:到|至|-|~).*?(-?\\d+(?:\\.\\d+)?)`, "i"),
|
||||
new RegExp(`(-?\\d+(?:\\.\\d+)?)\\s*[<≤<=]+\\s*${escapedVariable}\\s*[<≤<=]+\\s*(-?\\d+(?:\\.\\d+)?)`, "i"),
|
||||
new RegExp(`${escapedVariable}\\s*[≤<=]\\s*(-?\\d+(?:\\.\\d+)?).*?${escapedVariable}\\s*[≥>=]\\s*(-?\\d+(?:\\.\\d+)?)`, "i"),
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match) {
|
||||
let min: number, max: number;
|
||||
if (match.length === 4 && match[3]) {
|
||||
min = parseFloat(match[1]);
|
||||
max = parseFloat(match[2]) * Math.pow(10, parseFloat(match[3]));
|
||||
} else if (pattern.source.includes("≤.*≥")) {
|
||||
max = parseFloat(match[1].replace(/,/g, ""));
|
||||
min = parseFloat(match[2].replace(/,/g, ""));
|
||||
} else {
|
||||
min = parseFloat(match[1].replace(/,/g, ""));
|
||||
max = parseFloat(match[2].replace(/,/g, ""));
|
||||
}
|
||||
if (min > max) [min, max] = [max, min];
|
||||
return { min: Math.floor(min), max: Math.floor(max) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseAllConstraints(description: string): Record<string, Constraint> {
|
||||
const constraints: Record<string, Constraint> = {};
|
||||
const commonVariables = [
|
||||
"n", "m", "k", "q", "t", "l", "r", "x", "y", "z",
|
||||
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j",
|
||||
"a_i", "b_i", "c_i", "A_i", "B_i", "C_i",
|
||||
"s", "S", "w", "W", "h", "H",
|
||||
"数组长度", "序列长度", "字符串长度",
|
||||
"序列中的数", "数组中的数", "元素", "值",
|
||||
"权重", "代价", "距离", "时间", "长度", "宽度", "高度",
|
||||
];
|
||||
for (const variable of commonVariables) {
|
||||
const c = parseConstraint(description, variable);
|
||||
if (c) constraints[variable] = c;
|
||||
}
|
||||
const rangePatterns = [
|
||||
/(\d+)\s*[≤<=]\s*.*?[≤<=]\s*(\d+)/g,
|
||||
/(\d+)\s*到\s*(\d+)/g,
|
||||
/(\d+)\s*~\s*(\d+)/g,
|
||||
/\[(\d+),\s*(\d+)\]/g,
|
||||
];
|
||||
for (const pattern of rangePatterns) {
|
||||
let match;
|
||||
while ((match = pattern.exec(description)) !== null) {
|
||||
const min = parseInt(match[1], 10);
|
||||
const max = parseInt(match[2], 10);
|
||||
if (min < max) {
|
||||
const key = `range_${min}_${max}`;
|
||||
if (!constraints[key]) constraints[key] = { min, max };
|
||||
}
|
||||
}
|
||||
}
|
||||
return constraints;
|
||||
}
|
||||
|
||||
function generateValueInRange(min: number, max: number, caseNumber: number, totalCases = 10): number {
|
||||
const range = max - min;
|
||||
if (caseNumber === 1) return min;
|
||||
if (caseNumber === 2) return min + 1;
|
||||
if (caseNumber === totalCases) return max;
|
||||
if (caseNumber === totalCases - 1) return max - 1;
|
||||
if (caseNumber === 3) return min + Math.floor(range * 0.1);
|
||||
if (caseNumber === 4) return min + Math.floor(range * 0.25);
|
||||
if (caseNumber === 5) return min + Math.floor(range * 0.5);
|
||||
if (caseNumber === 6) return min + Math.floor(range * 0.75);
|
||||
if (caseNumber === 7) return min + Math.floor(range * 0.9);
|
||||
const segments = [0.05, 0.15, 0.35, 0.65, 0.85, 0.95];
|
||||
const seg = segments[caseNumber % segments.length];
|
||||
const value = min + Math.floor(range * (seg + (Math.random() * 0.1 - 0.05)));
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function generateArrayInput(
|
||||
caseNumber: number,
|
||||
nConstraint: Constraint | null,
|
||||
valueConstraint: Constraint | null,
|
||||
totalCases: number
|
||||
): string {
|
||||
const maxN = nConstraint?.max ?? 1000;
|
||||
const minN = nConstraint?.min ?? 1;
|
||||
const n = generateValueInRange(minN, maxN, caseNumber, totalCases);
|
||||
const maxVal = valueConstraint?.max ?? 1000;
|
||||
const minVal = valueConstraint?.min ?? -1000;
|
||||
let result = `${n}\n`;
|
||||
const values: number[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
values.push(generateValueInRange(minVal, maxVal, i + 1, n));
|
||||
}
|
||||
result += values.join(" ");
|
||||
return result;
|
||||
}
|
||||
|
||||
function generateGraphInput(
|
||||
caseNumber: number,
|
||||
nConstraint: Constraint | null,
|
||||
totalCases: number
|
||||
): string {
|
||||
const maxN = Math.min(nConstraint?.max ?? 1000, 1000);
|
||||
const minN = nConstraint?.min ?? 3;
|
||||
const n = generateValueInRange(minN, maxN, caseNumber, totalCases);
|
||||
const m = Math.min(Math.floor((n * (n - 1)) / 2), Math.floor(n * Math.log(n)));
|
||||
let result = `${n} ${m}\n`;
|
||||
const edges = new Set<string>();
|
||||
for (let i = 0; i < m; i++) {
|
||||
let u: number, v: number;
|
||||
do {
|
||||
u = Math.floor(Math.random() * n) + 1;
|
||||
v = Math.floor(Math.random() * n) + 1;
|
||||
} while (u === v || edges.has(`${Math.min(u, v)}-${Math.max(u, v)}`));
|
||||
edges.add(`${Math.min(u, v)}-${Math.max(u, v)}`);
|
||||
result += `${u} ${v}\n`;
|
||||
}
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
function generateStringInput(
|
||||
caseNumber: number,
|
||||
nConstraint: Constraint | null,
|
||||
totalCases: number
|
||||
): string {
|
||||
const maxN = nConstraint?.max ?? 100;
|
||||
const minN = nConstraint?.min ?? 1;
|
||||
const n = generateValueInRange(minN, maxN, caseNumber, totalCases);
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz";
|
||||
let str = "";
|
||||
for (let i = 0; i < n; i++) {
|
||||
str += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function generateMatrixInput(
|
||||
caseNumber: number,
|
||||
nConstraint: Constraint | null,
|
||||
valueConstraint: Constraint | null,
|
||||
totalCases: number
|
||||
): string {
|
||||
const maxN = Math.min(nConstraint?.max ?? 100, 100);
|
||||
const minN = nConstraint?.min ?? 2;
|
||||
const n = generateValueInRange(minN, maxN, caseNumber, totalCases);
|
||||
const m = n;
|
||||
const maxVal = valueConstraint?.max ?? 100;
|
||||
const minVal = valueConstraint?.min ?? 0;
|
||||
let result = `${n} ${m}\n`;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const row: number[] = [];
|
||||
for (let j = 0; j < m; j++) {
|
||||
row.push(Math.floor(Math.random() * (maxVal - minVal + 1)) + minVal);
|
||||
}
|
||||
result += row.join(" ") + "\n";
|
||||
}
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
function generateTreeInput(
|
||||
caseNumber: number,
|
||||
nConstraint: Constraint | null,
|
||||
totalCases: number
|
||||
): string {
|
||||
const maxN = Math.min(nConstraint?.max ?? 1000, 10000);
|
||||
const minN = Math.max(nConstraint?.min ?? 2, 2);
|
||||
const n = generateValueInRange(minN, maxN, caseNumber, totalCases);
|
||||
let result = `${n}\n`;
|
||||
const edges: [number, number][] = [];
|
||||
for (let i = 2; i <= n; i++) {
|
||||
const parent = Math.floor(Math.random() * (i - 1)) + 1;
|
||||
edges.push([parent, i]);
|
||||
}
|
||||
for (let i = edges.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[edges[i], edges[j]] = [edges[j], edges[i]];
|
||||
}
|
||||
for (const [u, v] of edges) {
|
||||
result += `${u} ${v}\n`;
|
||||
}
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
function generateQueryInput(
|
||||
caseNumber: number,
|
||||
nConstraint: Constraint | null,
|
||||
mConstraint: Constraint | null,
|
||||
valueConstraint: Constraint | null,
|
||||
totalCases: number
|
||||
): string {
|
||||
const maxN = nConstraint?.max ?? 1000;
|
||||
const minN = nConstraint?.min ?? 1;
|
||||
const n = generateValueInRange(minN, maxN, caseNumber, totalCases);
|
||||
const maxM = mConstraint?.max ?? 100;
|
||||
const minM = mConstraint?.min ?? 1;
|
||||
const m = generateValueInRange(minM, maxM, caseNumber, totalCases);
|
||||
const maxVal = valueConstraint?.max ?? 1000;
|
||||
const minVal = valueConstraint?.min ?? 1;
|
||||
let result = `${n} ${m}\n`;
|
||||
const values: number[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
values.push(generateValueInRange(minVal, maxVal, i + 1, n));
|
||||
}
|
||||
result += values.join(" ") + "\n";
|
||||
const queries: number[] = [];
|
||||
for (let i = 0; i < m; i++) {
|
||||
queries.push(generateValueInRange(minVal, maxVal, i + 1, m));
|
||||
}
|
||||
result += queries.join(" ");
|
||||
return result;
|
||||
}
|
||||
|
||||
function generateBinarySearchInput(
|
||||
caseNumber: number,
|
||||
description: string,
|
||||
totalCases: number
|
||||
): string {
|
||||
const nConstraint = parseConstraint(description, "n") ?? { min: 5, max: 100 };
|
||||
const mConstraint = parseConstraint(description, "m") ?? { min: 1, max: 10 };
|
||||
const valueConstraint = parseConstraint(description, "a_i") ?? parseConstraint(description, "q") ?? { min: 0, max: 100 };
|
||||
let n: number, m: number;
|
||||
switch (caseNumber) {
|
||||
case 1:
|
||||
n = Math.min(10, nConstraint.max);
|
||||
m = Math.min(5, mConstraint.max);
|
||||
break;
|
||||
case 2:
|
||||
n = Math.min(100, Math.floor(nConstraint.max * 0.1));
|
||||
m = Math.min(20, Math.floor(mConstraint.max * 0.2));
|
||||
break;
|
||||
case 3:
|
||||
n = nConstraint.min;
|
||||
m = mConstraint.min;
|
||||
break;
|
||||
case 4:
|
||||
n = Math.min(1000, nConstraint.max);
|
||||
m = Math.min(100, mConstraint.max);
|
||||
break;
|
||||
default:
|
||||
n = generateValueInRange(nConstraint.min, Math.min(500, nConstraint.max), caseNumber, totalCases);
|
||||
m = generateValueInRange(mConstraint.min, Math.min(50, mConstraint.max), caseNumber, totalCases);
|
||||
}
|
||||
const sequence: number[] = [];
|
||||
let currentValue = valueConstraint.min;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (i > 0 && Math.random() < 0.7) {
|
||||
const inc = Math.floor(Math.random() * 5) + 1;
|
||||
currentValue = Math.min(currentValue + inc, valueConstraint.max);
|
||||
}
|
||||
sequence.push(currentValue);
|
||||
}
|
||||
const queries: number[] = [];
|
||||
for (let i = 0; i < m; i++) {
|
||||
if (i < m / 2 && sequence.length > 0) {
|
||||
queries.push(sequence[Math.floor(Math.random() * sequence.length)]!);
|
||||
} else {
|
||||
queries.push(
|
||||
Math.random() < 0.5 && sequence.length > 0
|
||||
? sequence[Math.floor(Math.random() * sequence.length)]!
|
||||
: generateValueInRange(valueConstraint.min, valueConstraint.max, i, m)
|
||||
);
|
||||
}
|
||||
}
|
||||
return `${n} ${m}\n${sequence.join(" ")}\n${queries.join(" ")}`;
|
||||
}
|
||||
|
||||
function generateMockInput(description: string, caseNumber: number, totalCases = 10): string {
|
||||
const desc = description.toLowerCase();
|
||||
const allConstraints = parseAllConstraints(description);
|
||||
|
||||
if (desc.includes("单调不减") && desc.includes("查找") && desc.includes("询问")) {
|
||||
return generateBinarySearchInput(caseNumber, description, totalCases);
|
||||
}
|
||||
|
||||
const nConstraint = allConstraints["n"] ?? allConstraints["N"] ?? parseConstraint(description, "n");
|
||||
const mConstraint = allConstraints["m"] ?? allConstraints["M"] ?? parseConstraint(description, "m");
|
||||
const valueConstraint =
|
||||
allConstraints["序列中的数"] ?? allConstraints["数组中的数"] ?? allConstraints["元素"] ?? allConstraints["值"] ??
|
||||
parseConstraint(description, "序列中的数") ?? parseConstraint(description, "value");
|
||||
|
||||
let effectiveN = nConstraint;
|
||||
if (!effectiveN) {
|
||||
const rangeKeys = Object.keys(allConstraints).filter((k) => k.startsWith("range_"));
|
||||
if (rangeKeys.length > 0) {
|
||||
const maxRange = rangeKeys.reduce((a, b) =>
|
||||
(allConstraints[b]?.max ?? 0) > (allConstraints[a]?.max ?? 0) ? b : a
|
||||
);
|
||||
effectiveN = allConstraints[maxRange] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
if (desc.includes("序列") || desc.includes("数组") || desc.includes("array")) {
|
||||
return generateArrayInput(caseNumber, effectiveN ?? null, valueConstraint ?? null, totalCases);
|
||||
}
|
||||
if (desc.includes("图") || desc.includes("graph")) {
|
||||
return generateGraphInput(caseNumber, effectiveN ?? null, totalCases);
|
||||
}
|
||||
if (desc.includes("字符串") || desc.includes("string")) {
|
||||
return generateStringInput(caseNumber, effectiveN ?? null, totalCases);
|
||||
}
|
||||
if (desc.includes("矩阵") || desc.includes("matrix")) {
|
||||
return generateMatrixInput(caseNumber, effectiveN ?? null, valueConstraint ?? null, totalCases);
|
||||
}
|
||||
if (desc.includes("树") || desc.includes("tree")) {
|
||||
return generateTreeInput(caseNumber, effectiveN ?? null, totalCases);
|
||||
}
|
||||
if (desc.includes("询问") || desc.includes("query")) {
|
||||
return generateQueryInput(caseNumber, effectiveN ?? null, mConstraint ?? null, valueConstraint ?? null, totalCases);
|
||||
}
|
||||
|
||||
const maxN = effectiveN?.max ?? 100;
|
||||
const minN = effectiveN?.min ?? 1;
|
||||
const n = generateValueInRange(minN, maxN, caseNumber, totalCases);
|
||||
const keys = Object.keys(allConstraints);
|
||||
if (keys.length > 1) {
|
||||
let result = `${n}\n`;
|
||||
for (const key of keys.slice(1, 3)) {
|
||||
if (key !== "n" && key !== "N" && !key.startsWith("range_")) {
|
||||
const c = allConstraints[key];
|
||||
if (c) result += `${generateValueInRange(c.min, c.max, caseNumber, totalCases)}\n`;
|
||||
}
|
||||
}
|
||||
return result.trim();
|
||||
}
|
||||
return `${n}`;
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
export interface FallbackParams {
|
||||
englishName: string;
|
||||
description: string;
|
||||
standardCode: string;
|
||||
testCaseCount: number;
|
||||
}
|
||||
|
||||
export interface FallbackResult {
|
||||
testCases: { input: string; output: string }[];
|
||||
outputDirName: string;
|
||||
zipFileName: string;
|
||||
}
|
||||
|
||||
export type ProgressCallback = (progress: number, message: string, generatedCount: number) => void;
|
||||
|
||||
export async function generateFallbackTestCases(
|
||||
params: FallbackParams,
|
||||
progressCallback?: ProgressCallback
|
||||
): Promise<FallbackResult> {
|
||||
const randomSuffix = Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 7);
|
||||
const outputDirName = `${params.englishName}_${randomSuffix}`;
|
||||
const outputDir = path.join(OUTPUT_BASE, outputDirName);
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
|
||||
const testCases: { input: string; output: string }[] = [];
|
||||
|
||||
for (let i = 1; i <= params.testCaseCount; i++) {
|
||||
if (progressCallback) {
|
||||
const progress = 0.3 + (i / params.testCaseCount) * 0.6;
|
||||
progressCallback(progress, `正在生成第 ${i}/${params.testCaseCount} 组测试用例...`, i - 1);
|
||||
}
|
||||
|
||||
const inputData = generateMockInput(params.description, i, params.testCaseCount);
|
||||
let outputData: string | null = null;
|
||||
|
||||
for (let retry = 0; retry <= MAX_RETRIES; retry++) {
|
||||
try {
|
||||
outputData = await executeStandardCode(params.standardCode, inputData);
|
||||
break;
|
||||
} catch (err) {
|
||||
if (retry === MAX_RETRIES) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!outputData) continue;
|
||||
|
||||
await fs.writeFile(path.join(outputDir, `${params.englishName}${i}.in`), inputData);
|
||||
await fs.writeFile(path.join(outputDir, `${params.englishName}${i}.out`), outputData);
|
||||
testCases.push({ input: inputData, output: outputData });
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(0.9, "正在创建ZIP压缩包...", testCases.length);
|
||||
}
|
||||
|
||||
const zipFileName = `${params.englishName}_${randomSuffix}.zip`;
|
||||
const zipPath = path.join(OUTPUT_BASE, zipFileName);
|
||||
await createZipFile(outputDir, zipPath, params.englishName);
|
||||
|
||||
return { testCases, outputDirName, zipFileName };
|
||||
}
|
||||
|
||||
export function getOutputBase(): string {
|
||||
return OUTPUT_BASE;
|
||||
}
|
||||
|
||||
/** AI 生成的用例:用标程跑出 output,写入 .in/.out 并打包 */
|
||||
export interface AITestCaseInput {
|
||||
input: string;
|
||||
}
|
||||
|
||||
export async function runAIGeneratedCases(
|
||||
params: FallbackParams,
|
||||
aiCases: AITestCaseInput[],
|
||||
progressCallback?: ProgressCallback
|
||||
): Promise<FallbackResult> {
|
||||
const randomSuffix = Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 7);
|
||||
const outputDirName = `${params.englishName}_${randomSuffix}`;
|
||||
const outputDir = path.join(OUTPUT_BASE, outputDirName);
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
|
||||
const testCases: { input: string; output: string }[] = [];
|
||||
const total = aiCases.length;
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
if (progressCallback) {
|
||||
const progress = 0.3 + ((i + 1) / total) * 0.6;
|
||||
progressCallback(progress, `正在处理第 ${i + 1}/${total} 组测试用例...`, i);
|
||||
}
|
||||
|
||||
const inputData = aiCases[i]!.input;
|
||||
let outputData: string | null = null;
|
||||
|
||||
for (let retry = 0; retry <= MAX_RETRIES; retry++) {
|
||||
try {
|
||||
outputData = await executeStandardCode(params.standardCode, inputData);
|
||||
break;
|
||||
} catch {
|
||||
if (retry === MAX_RETRIES) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!outputData) continue;
|
||||
|
||||
const oneBased = i + 1;
|
||||
await fs.writeFile(path.join(outputDir, `${params.englishName}${oneBased}.in`), inputData);
|
||||
await fs.writeFile(path.join(outputDir, `${params.englishName}${oneBased}.out`), outputData);
|
||||
testCases.push({ input: inputData, output: outputData });
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback(0.9, "正在创建ZIP压缩包...", testCases.length);
|
||||
}
|
||||
|
||||
const zipFileName = `${params.englishName}_${randomSuffix}.zip`;
|
||||
const zipPath = path.join(OUTPUT_BASE, zipFileName);
|
||||
await createZipFile(outputDir, zipPath, params.englishName);
|
||||
|
||||
return { testCases, outputDirName, zipFileName };
|
||||
}
|
||||
9
app/lib/testcase-backend/index.ts
Normal file
9
app/lib/testcase-backend/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { getTask, setTask, createTask, getEstimatedTime, findRunningTaskByEnglishName } from "./store";
|
||||
export type { Task } from "./types";
|
||||
export type { CreateTaskParams } from "./types";
|
||||
export { validateStandardCode, executeStandardCode } from "./runner";
|
||||
export { generateFallbackTestCases, getOutputBase } from "./generator";
|
||||
export type { FallbackParams, FallbackResult, ProgressCallback } from "./generator";
|
||||
export { processTask, getZipPathForTask } from "./process";
|
||||
export { generateTestCases, isQwenAvailable } from "./qwen-service";
|
||||
export type { AITestCase, GenerateTestCasesParams } from "./qwen-service";
|
||||
156
app/lib/testcase-backend/process.ts
Normal file
156
app/lib/testcase-backend/process.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import path from "path";
|
||||
import { getTask, setTask } from "./store";
|
||||
import { validateStandardCode } from "./runner";
|
||||
import { generateFallbackTestCases, runAIGeneratedCases, getOutputBase } from "./generator";
|
||||
import { generateTestCases, isQwenAvailable } from "./qwen-service";
|
||||
|
||||
function formatErrorMessage(err: unknown): string {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if ((err as Error & { code?: string }).code === "COMPILATION_ERROR") {
|
||||
return `C++程序编译失败: ${message}`;
|
||||
}
|
||||
if ((err as Error & { code?: string }).code === "EXECUTION_ERROR") {
|
||||
return `程序执行错误: ${message}`;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
function updateTaskProgress(
|
||||
taskId: string,
|
||||
status: "pending" | "running" | "completed" | "failed",
|
||||
progress: number,
|
||||
message: string,
|
||||
generatedCount?: number
|
||||
): void {
|
||||
const task = getTask(taskId);
|
||||
if (!task) return;
|
||||
task.status = status;
|
||||
task.progress = Math.min(100, Math.max(0, progress));
|
||||
task.updatedAt = new Date();
|
||||
task.currentMessage = message;
|
||||
if (generatedCount !== undefined) task.generatedCases = generatedCount;
|
||||
setTask(taskId, task);
|
||||
}
|
||||
|
||||
export async function processTask(taskId: string): Promise<void> {
|
||||
const task = getTask(taskId);
|
||||
if (!task) {
|
||||
console.error(`任务不存在: ${taskId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
updateTaskProgress(taskId, "running", 10, "正在初始化生成环境...");
|
||||
|
||||
await validateStandardCode(task.standardCode);
|
||||
updateTaskProgress(taskId, "running", 20, "标准程序验证通过,开始生成测试数据...");
|
||||
|
||||
let result: { testCases: { input: string; output: string }[]; outputDirName: string; zipFileName: string };
|
||||
|
||||
if (isQwenAvailable()) {
|
||||
try {
|
||||
updateTaskProgress(taskId, "running", 22, "正在调用阿里云大模型生成测试用例...", 0);
|
||||
const aiCases = await generateTestCases({
|
||||
title: task.title,
|
||||
englishName: task.englishName,
|
||||
description: task.description,
|
||||
standardCode: task.standardCode,
|
||||
testCaseCount: task.testCaseCount,
|
||||
serviceLevel: task.serviceLevel,
|
||||
});
|
||||
if (aiCases.length > 0) {
|
||||
updateTaskProgress(taskId, "running", 25, "AI 生成完成,正在用标程计算输出...", 0);
|
||||
result = await runAIGeneratedCases(
|
||||
{
|
||||
englishName: task.englishName,
|
||||
description: task.description,
|
||||
standardCode: task.standardCode,
|
||||
testCaseCount: task.testCaseCount,
|
||||
},
|
||||
aiCases,
|
||||
(progress, message, generatedCount) => {
|
||||
updateTaskProgress(
|
||||
taskId,
|
||||
"running",
|
||||
25 + Math.floor(progress * 65),
|
||||
message,
|
||||
generatedCount
|
||||
);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
throw new Error("AI 未返回有效用例");
|
||||
}
|
||||
} catch (aiErr) {
|
||||
console.warn("AI 生成失败,使用备用算法:", aiErr);
|
||||
updateTaskProgress(taskId, "running", 20, "使用智能算法生成测试用例...", 0);
|
||||
result = await generateFallbackTestCases(
|
||||
{
|
||||
englishName: task.englishName,
|
||||
description: task.description,
|
||||
standardCode: task.standardCode,
|
||||
testCaseCount: task.testCaseCount,
|
||||
},
|
||||
(progress, message, generatedCount) => {
|
||||
updateTaskProgress(
|
||||
taskId,
|
||||
"running",
|
||||
20 + Math.floor(progress * 70),
|
||||
message,
|
||||
generatedCount
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
result = await generateFallbackTestCases(
|
||||
{
|
||||
englishName: task.englishName,
|
||||
description: task.description,
|
||||
standardCode: task.standardCode,
|
||||
testCaseCount: task.testCaseCount,
|
||||
},
|
||||
(progress, message, generatedCount) => {
|
||||
updateTaskProgress(
|
||||
taskId,
|
||||
"running",
|
||||
20 + Math.floor(progress * 70),
|
||||
message,
|
||||
generatedCount
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
updateTaskProgress(taskId, "running", 95, "正在打包文件...", result.testCases.length);
|
||||
|
||||
const finalTask = getTask(taskId);
|
||||
if (finalTask) {
|
||||
finalTask.status = "completed";
|
||||
finalTask.progress = 100;
|
||||
finalTask.updatedAt = new Date();
|
||||
finalTask.completedAt = new Date();
|
||||
finalTask.generatedCases = result.testCases.length;
|
||||
finalTask.zipFileName = result.zipFileName;
|
||||
finalTask.outputDirName = result.outputDirName;
|
||||
setTask(taskId, finalTask);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`任务失败: ${taskId}`, err);
|
||||
const finalTask = getTask(taskId);
|
||||
if (finalTask) {
|
||||
finalTask.status = "failed";
|
||||
finalTask.progress = 0;
|
||||
finalTask.errorMessage = formatErrorMessage(err);
|
||||
finalTask.updatedAt = new Date();
|
||||
finalTask.failedAt = new Date();
|
||||
setTask(taskId, finalTask);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getZipPathForTask(taskId: string): string | null {
|
||||
const task = getTask(taskId);
|
||||
if (!task?.zipFileName) return null;
|
||||
return path.join(getOutputBase(), task.zipFileName);
|
||||
}
|
||||
252
app/lib/testcase-backend/qwen-service.ts
Normal file
252
app/lib/testcase-backend/qwen-service.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* 阿里云通义大模型服务(DashScope API)
|
||||
* 用于测试点生成的 AI 输入数据生成
|
||||
*/
|
||||
|
||||
const DASHSCOPE_BASE =
|
||||
"https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation";
|
||||
|
||||
export interface AITestCase {
|
||||
input: string;
|
||||
expectedOutput?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface GenerateTestCasesParams {
|
||||
title: string;
|
||||
englishName: string;
|
||||
description: string;
|
||||
standardCode: string;
|
||||
testCaseCount: number;
|
||||
serviceLevel?: string;
|
||||
}
|
||||
|
||||
function getApiKey(): string | undefined {
|
||||
return process.env.DASHSCOPE_API_KEY;
|
||||
}
|
||||
|
||||
function getModel(): string {
|
||||
return process.env.QWEN_MODEL || "qwen-max";
|
||||
}
|
||||
|
||||
function isAIEnabled(): boolean {
|
||||
const key = getApiKey();
|
||||
return (
|
||||
process.env.ENABLE_AI === "true" &&
|
||||
!!key &&
|
||||
key !== "your_dashscope_api_key_here"
|
||||
);
|
||||
}
|
||||
|
||||
function buildPrompt(params: GenerateTestCasesParams): string {
|
||||
const { title, description, standardCode, testCaseCount } = params;
|
||||
return `你是一个专业的信息学奥林匹克竞赛测试数据生成专家。请根据以下题目信息,生成 ${testCaseCount} 组测试输入数据。
|
||||
|
||||
## 题目信息
|
||||
**标题**: ${title}
|
||||
|
||||
**题目描述**:
|
||||
${description}
|
||||
|
||||
**标准程序** (仅作参考,输出将由标准程序计算):
|
||||
\`\`\`cpp
|
||||
${standardCode}
|
||||
\`\`\`
|
||||
|
||||
## 要求
|
||||
1. 严格按照题目描述中的**输入格式**生成,注意多行顺序
|
||||
2. 所有数据必须满足题目约束(范围、单调性等)
|
||||
3. 不要使用省略号、占位符或「此处省略」
|
||||
4. 覆盖边界:最小值、最大值、小规模、大规模
|
||||
5. 每组测试用例的输入数据要完整、可直接作为程序 stdin
|
||||
|
||||
## 输出格式(二选一)
|
||||
|
||||
**方式一 JSON**(推荐):
|
||||
\`\`\`json
|
||||
{
|
||||
"test_cases": [
|
||||
{ "input": "第一组输入的完整内容,多行用\\n连接" },
|
||||
{ "input": "第二组输入..." }
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**方式二 文本**:
|
||||
测试用例1:
|
||||
[第一组输入的完整内容,多行按行写]
|
||||
|
||||
测试用例2:
|
||||
[第二组输入...]
|
||||
|
||||
请生成 ${testCaseCount} 组测试输入数据:`;
|
||||
}
|
||||
|
||||
async function callAPI(prompt: string): Promise<string> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error("未配置 DASHSCOPE_API_KEY 环境变量");
|
||||
}
|
||||
|
||||
const res = await fetch(DASHSCOPE_BASE, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: getModel(),
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
"你是信息学奥林匹克竞赛测试数据生成专家。根据题目描述生成测试输入,严格按要求的 JSON 或文本格式返回,不要省略或占位。",
|
||||
},
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
max_tokens: 8000,
|
||||
temperature: 0.3,
|
||||
result_format: "message",
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(180_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`DashScope API 错误 ${res.status}: ${text.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
output?: { text?: string; choices?: Array<{ message?: { content?: string } }> };
|
||||
};
|
||||
const text =
|
||||
data.output?.text ??
|
||||
data.output?.choices?.[0]?.message?.content ??
|
||||
"";
|
||||
return text;
|
||||
}
|
||||
|
||||
function processTestCases(raw: unknown[], expectedCount: number): AITestCase[] {
|
||||
const result: AITestCase[] = [];
|
||||
for (let i = 0; i < Math.min(raw.length, expectedCount); i++) {
|
||||
const item = raw[i];
|
||||
if (typeof item === "string" && item.trim()) {
|
||||
result.push({ input: item.trim() });
|
||||
continue;
|
||||
}
|
||||
if (item && typeof item === "object" && "input" in item) {
|
||||
const obj = item as { input?: unknown; expectedOutput?: unknown };
|
||||
const input = obj.input;
|
||||
if (typeof input === "string" && input.trim()) {
|
||||
result.push({
|
||||
input: input.trim().replace(/\\n/g, "\n"),
|
||||
...(typeof obj.expectedOutput === "string" && obj.expectedOutput
|
||||
? { expectedOutput: obj.expectedOutput }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseJSONResponse(aiResponse: string, expectedCount: number): AITestCase[] {
|
||||
// 1) 直接解析整段
|
||||
const trimmed = aiResponse.trim();
|
||||
try {
|
||||
const direct = JSON.parse(trimmed) as { test_cases?: unknown[]; testCases?: unknown[] };
|
||||
const arr = direct.test_cases ?? direct.testCases;
|
||||
if (Array.isArray(arr) && arr.length > 0) {
|
||||
return processTestCases(arr, expectedCount);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// 2) ```json ... ```
|
||||
const jsonBlock = aiResponse.match(/```json\s*([\s\S]*?)\s*```/);
|
||||
if (jsonBlock) {
|
||||
try {
|
||||
const data = JSON.parse(jsonBlock[1].trim()) as {
|
||||
test_cases?: unknown[];
|
||||
testCases?: unknown[];
|
||||
};
|
||||
const arr = data.test_cases ?? data.testCases;
|
||||
if (Array.isArray(arr) && arr.length > 0) {
|
||||
return processTestCases(arr, expectedCount);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseTextResponse(text: string, expectedCount: number): AITestCase[] {
|
||||
const cases: AITestCase[] = [];
|
||||
const parts = text.split(/(?:测试用例|测试数据|Test\s*Case)\s*\d+\s*[::]?\s*\n/i);
|
||||
for (let i = 1; i < parts.length && cases.length < expectedCount; i++) {
|
||||
const block = parts[i]!.trim();
|
||||
const nextCase = block.search(/(?:测试用例|测试数据|Test\s*Case)\s*\d+\s*[::]/i);
|
||||
const content = nextCase >= 0 ? block.slice(0, nextCase).trim() : block;
|
||||
if (content && !content.includes("...") && !content.includes("省略")) {
|
||||
cases.push({ input: content });
|
||||
}
|
||||
}
|
||||
return cases;
|
||||
}
|
||||
|
||||
function parseAIResponse(aiResponse: string, expectedCount: number): AITestCase[] {
|
||||
const fromJson = parseJSONResponse(aiResponse, expectedCount);
|
||||
if (fromJson.length > 0) return fromJson;
|
||||
return parseTextResponse(aiResponse, expectedCount);
|
||||
}
|
||||
|
||||
function validateParsedCases(cases: AITestCase[]): AITestCase[] {
|
||||
return cases.filter((c) => {
|
||||
const input = c.input ?? "";
|
||||
if (!input.trim()) return false;
|
||||
if (input.includes("...") || input.includes("......") || input.includes("省略")) return false;
|
||||
if (input.includes("占位符") || input.includes("placeholder")) return false;
|
||||
if (/^\s*[{\[]/.test(input) && (input.includes('"input"') || input.includes("test_cases"))) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function isQwenAvailable(): boolean {
|
||||
return isAIEnabled();
|
||||
}
|
||||
|
||||
export async function generateTestCases(params: GenerateTestCasesParams): Promise<AITestCase[]> {
|
||||
if (!isAIEnabled()) {
|
||||
throw new Error("AI 未启用或未配置 DASHSCOPE_API_KEY");
|
||||
}
|
||||
|
||||
const maxRetries = 2;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
|
||||
try {
|
||||
const prompt = buildPrompt(params);
|
||||
const response = await callAPI(prompt);
|
||||
const parsed = parseAIResponse(response, params.testCaseCount);
|
||||
const valid = validateParsedCases(parsed);
|
||||
|
||||
if (valid.length > 0) {
|
||||
return valid.slice(0, params.testCaseCount);
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
if (attempt <= maxRetries) {
|
||||
await new Promise((r) => setTimeout(r, 1000 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("AI 生成测试用例失败");
|
||||
}
|
||||
65
app/lib/testcase-backend/runner.ts
Normal file
65
app/lib/testcase-backend/runner.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const CPP_STANDARD = process.env.CPP_STANDARD || "c++14";
|
||||
const CPP_FLAGS = process.env.CPP_FLAGS || "-Wall -Wno-unused-variable";
|
||||
|
||||
export async function validateStandardCode(code: string): Promise<void> {
|
||||
if (!code?.trim()) {
|
||||
throw new Error("标准程序不能为空");
|
||||
}
|
||||
if (!code.includes("#include") || !code.includes("main")) {
|
||||
throw new Error("标准程序必须包含 #include 和 main 函数");
|
||||
}
|
||||
|
||||
const os = await import("os");
|
||||
const tempDir = path.join(os.tmpdir(), "testcase_validate_" + Date.now());
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const cppFile = path.join(tempDir, "test.cpp");
|
||||
const exeFile = path.join(tempDir, "test");
|
||||
await fs.writeFile(cppFile, code);
|
||||
const compileCommand = `g++ -o "${exeFile}" "${cppFile}" -std=${CPP_STANDARD} ${CPP_FLAGS}`;
|
||||
await execAsync(compileCommand, { timeout: 10000 });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const e = new Error(`编译失败: ${message}`);
|
||||
(e as Error & { code?: string }).code = "COMPILATION_ERROR";
|
||||
throw e;
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeStandardCode(code: string, input: string): Promise<string> {
|
||||
const os = await import("os");
|
||||
const tempDir = path.join(os.tmpdir(), "testcase_run_" + Date.now());
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
|
||||
const cppFile = path.join(tempDir, "solution.cpp");
|
||||
const exeFile = path.join(tempDir, "solution");
|
||||
const inputFile = path.join(tempDir, "input.txt");
|
||||
|
||||
try {
|
||||
await fs.writeFile(cppFile, code);
|
||||
const compileCommand = `g++ -o "${exeFile}" "${cppFile}" -std=${CPP_STANDARD} ${CPP_FLAGS}`;
|
||||
await execAsync(compileCommand);
|
||||
await fs.writeFile(inputFile, input);
|
||||
const executeCommand = `"${exeFile}" < "${inputFile}"`;
|
||||
const { stdout, stderr } = await execAsync(executeCommand, { timeout: 10000 });
|
||||
if (stderr) console.warn("C++ execution warning:", stderr);
|
||||
return stdout.trim();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const e = new Error(`程序执行失败: ${message}`);
|
||||
(e as Error & { code?: string }).code = "EXECUTION_ERROR";
|
||||
throw e;
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
52
app/lib/testcase-backend/store.ts
Normal file
52
app/lib/testcase-backend/store.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Task, CreateTaskParams } from "./types";
|
||||
|
||||
const tasks = new Map<string, Task>();
|
||||
|
||||
export function getTask(taskId: string): Task | undefined {
|
||||
return tasks.get(taskId);
|
||||
}
|
||||
|
||||
export function setTask(taskId: string, task: Task): void {
|
||||
tasks.set(taskId, task);
|
||||
}
|
||||
|
||||
export function createTask(taskId: string, params: CreateTaskParams): Task {
|
||||
const task: Task = {
|
||||
id: taskId,
|
||||
status: "pending",
|
||||
title: params.title.trim(),
|
||||
englishName: params.englishName.trim().toLowerCase(),
|
||||
description: params.description.trim(),
|
||||
standardCode: params.standardCode.trim(),
|
||||
testCaseCount: params.testCaseCount,
|
||||
serviceLevel: params.serviceLevel || "standard",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
progress: 0,
|
||||
errorMessage: null,
|
||||
generatedCases: 0,
|
||||
};
|
||||
tasks.set(taskId, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
export function findRunningTaskByEnglishName(englishName: string): Task | undefined {
|
||||
const name = englishName.trim().toLowerCase();
|
||||
for (const task of tasks.values()) {
|
||||
if (task.englishName === name && (task.status === "pending" || task.status === "running")) {
|
||||
return task;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getEstimatedTime(serviceLevel: string, caseCount: number): number {
|
||||
const baseTime = Math.ceil(caseCount / 2);
|
||||
const multiplier: Record<string, number> = {
|
||||
standard: 1.0,
|
||||
domestic: 1.5,
|
||||
pro: 1.2,
|
||||
max: 2.0,
|
||||
};
|
||||
return Math.max(30, baseTime * (multiplier[serviceLevel] ?? 1.0));
|
||||
}
|
||||
29
app/lib/testcase-backend/types.ts
Normal file
29
app/lib/testcase-backend/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface Task {
|
||||
id: string;
|
||||
status: "pending" | "running" | "completed" | "failed";
|
||||
title: string;
|
||||
englishName: string;
|
||||
description: string;
|
||||
standardCode: string;
|
||||
testCaseCount: number;
|
||||
serviceLevel: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
progress: number;
|
||||
currentMessage?: string;
|
||||
errorMessage?: string | null;
|
||||
generatedCases: number;
|
||||
completedAt?: Date;
|
||||
failedAt?: Date;
|
||||
outputDirName?: string;
|
||||
zipFileName?: string;
|
||||
}
|
||||
|
||||
export interface CreateTaskParams {
|
||||
title: string;
|
||||
englishName: string;
|
||||
description: string;
|
||||
standardCode: string;
|
||||
testCaseCount: number;
|
||||
serviceLevel: string;
|
||||
}
|
||||
31
app/lib/testcase-backend/zip.ts
Normal file
31
app/lib/testcase-backend/zip.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import archiver from "archiver";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export function createZipFile(
|
||||
sourceDir: string,
|
||||
zipPath: string,
|
||||
englishName: string | null
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const output = fs.createWriteStream(zipPath);
|
||||
const archive = archiver("zip", { zlib: { level: 9 } });
|
||||
|
||||
output.on("close", () => resolve());
|
||||
archive.on("error", reject);
|
||||
archive.pipe(output);
|
||||
|
||||
if (englishName) {
|
||||
const files = fs.readdirSync(sourceDir);
|
||||
const targetFiles = files.filter(
|
||||
(f) => f.startsWith(englishName) && (f.endsWith(".in") || f.endsWith(".out"))
|
||||
);
|
||||
for (const file of targetFiles) {
|
||||
archive.file(path.join(sourceDir, file), { name: file });
|
||||
}
|
||||
} else {
|
||||
archive.directory(sourceDir, false);
|
||||
}
|
||||
archive.finalize();
|
||||
});
|
||||
}
|
||||
6
app/lib/utils.ts
Normal file
6
app/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user