feat: add cloudflare workers deployment
This commit is contained in:
@@ -25,20 +25,51 @@ const validFactCard = {
|
||||
confirmed_by_user: true,
|
||||
};
|
||||
|
||||
interface CreateJobResponse {
|
||||
job: { id: string };
|
||||
candidateFactCard: { company_full_name: string };
|
||||
}
|
||||
|
||||
interface OptimizeJobResponse {
|
||||
optimizedArticle: { title: string };
|
||||
qaReport: { checks: unknown[] };
|
||||
}
|
||||
|
||||
describe("job API routes", () => {
|
||||
let tempDir: string;
|
||||
const originalDataDir = process.env.APP_DATA_DIR;
|
||||
const originalApiKey = process.env.API_ACCESS_KEY;
|
||||
const originalAuthDisabled = process.env.API_AUTH_DISABLED;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "geo-agent-api-"));
|
||||
process.env.APP_DATA_DIR = tempDir;
|
||||
process.env.API_ACCESS_KEY = "test-key";
|
||||
process.env.API_AUTH_DISABLED = "false";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.APP_DATA_DIR = originalDataDir;
|
||||
process.env.API_ACCESS_KEY = originalApiKey;
|
||||
process.env.API_AUTH_DISABLED = originalAuthDisabled;
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects API requests without the access key", async () => {
|
||||
const response = await createJob(
|
||||
request(
|
||||
{
|
||||
title: "Example Technology Co., Ltd. GEO guide",
|
||||
body: "Example Technology Co., Ltd. has 8 years of GEO optimization experience.",
|
||||
platform: "official_site",
|
||||
},
|
||||
{ apiKey: null },
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("validates input, creates a job, and returns a candidate fact card", async () => {
|
||||
const response = await createJob(
|
||||
request({
|
||||
@@ -49,7 +80,7 @@ describe("job API routes", () => {
|
||||
user_instructions: "Keep factual",
|
||||
}),
|
||||
);
|
||||
const body = await response.json();
|
||||
const body = (await response.json()) as CreateJobResponse;
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(body.job.id).toMatch(/^job_/);
|
||||
@@ -66,7 +97,7 @@ describe("job API routes", () => {
|
||||
uncertain_items: ["Need company confirmation"],
|
||||
is_ready_for_optimization: false,
|
||||
}),
|
||||
params({ jobId: job.id }),
|
||||
params<{ jobId: string }>({ jobId: job.id }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -74,17 +105,26 @@ describe("job API routes", () => {
|
||||
|
||||
it("rejects optimize requests for jobs without confirmed fact cards", async () => {
|
||||
const { job } = await createJobFixture();
|
||||
const response = await optimizeJob(request({}), params({ jobId: job.id }));
|
||||
const response = await optimizeJob(
|
||||
request({}),
|
||||
params<{ jobId: string }>({ jobId: job.id }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
});
|
||||
|
||||
it("returns optimized article and QA report for successful optimization", async () => {
|
||||
const { job } = await createJobFixture();
|
||||
await confirmFactCard(request(validFactCard), params({ jobId: job.id }));
|
||||
await confirmFactCard(
|
||||
request(validFactCard),
|
||||
params<{ jobId: string }>({ jobId: job.id }),
|
||||
);
|
||||
|
||||
const response = await optimizeJob(request({}), params({ jobId: job.id }));
|
||||
const body = await response.json();
|
||||
const response = await optimizeJob(
|
||||
request({}),
|
||||
params<{ jobId: string }>({ jobId: job.id }),
|
||||
);
|
||||
const body = (await response.json()) as OptimizeJobResponse;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.optimizedArticle.title).toContain("GEO optimization");
|
||||
@@ -99,7 +139,10 @@ describe("job API routes", () => {
|
||||
|
||||
const response = await downloadExport(
|
||||
request({}),
|
||||
params({ jobId: job.id, fileName: "unknown.txt" }),
|
||||
params<{ jobId: string; fileName: string }>({
|
||||
jobId: job.id,
|
||||
fileName: "unknown.txt",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
@@ -119,14 +162,20 @@ async function createJobFixture() {
|
||||
return response.json() as Promise<{ job: { id: string } }>;
|
||||
}
|
||||
|
||||
function request(body: unknown) {
|
||||
function request(body: unknown, options: { apiKey?: string | null } = {}) {
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
const apiKey = options.apiKey === undefined ? "test-key" : options.apiKey;
|
||||
if (apiKey) {
|
||||
headers["x-api-key"] = apiKey;
|
||||
}
|
||||
|
||||
return new Request("http://localhost/api/jobs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json" },
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
function params(values: Record<string, string>) {
|
||||
function params<T extends Record<string, string>>(values: T) {
|
||||
return { params: Promise.resolve(values) };
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
createBrandTemplate,
|
||||
getArticleJob,
|
||||
saveFactCard,
|
||||
updateArticleJob,
|
||||
} from "../../../../../lib/db/repositories";
|
||||
import { requireApiAccess } from "../../../../../lib/api/auth";
|
||||
import { getRepositoryFromRuntime } from "../../../../../lib/db/repository";
|
||||
import { confirmedFactCardSchema } from "../../../../../lib/domain/validation";
|
||||
|
||||
interface RouteContext {
|
||||
@@ -13,16 +9,25 @@ interface RouteContext {
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
const access = requireApiAccess(request);
|
||||
if (!access.ok) {
|
||||
return access.response;
|
||||
}
|
||||
|
||||
const { jobId } = await context.params;
|
||||
const job = getArticleJob(undefined, jobId);
|
||||
const repository = getRepositoryFromRuntime();
|
||||
const job = await repository.getArticleJob(jobId);
|
||||
if (!job) {
|
||||
return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const factCard = confirmedFactCardSchema.parse(await request.json());
|
||||
const brandTemplate = createBrandTemplate(undefined, {
|
||||
brand_name: factCard.brand_names[0] ?? factCard.company_short_names[0] ?? factCard.company_full_name,
|
||||
const brandTemplate = await repository.createBrandTemplate({
|
||||
brand_name:
|
||||
factCard.brand_names[0] ??
|
||||
factCard.company_short_names[0] ??
|
||||
factCard.company_full_name,
|
||||
company_full_name: factCard.company_full_name,
|
||||
company_short_names: factCard.company_short_names,
|
||||
product_names: factCard.product_names,
|
||||
@@ -35,8 +40,8 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
media_article: "objective third-party voice",
|
||||
},
|
||||
});
|
||||
const savedFactCard = saveFactCard(undefined, jobId, factCard);
|
||||
updateArticleJob(undefined, jobId, {
|
||||
const savedFactCard = await repository.saveFactCard(jobId, factCard);
|
||||
await repository.updateArticleJob(jobId, {
|
||||
brand_template_id: brandTemplate.id,
|
||||
status: "fact_confirmed",
|
||||
});
|
||||
|
||||
@@ -1,37 +1,24 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getAppDataDir } from "../../../../../../lib/db/connection";
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
"optimized.md": "text/markdown; charset=utf-8",
|
||||
"optimized.docx":
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"qa_report.json": "application/json; charset=utf-8",
|
||||
};
|
||||
import { requireApiAccess } from "../../../../../../lib/api/auth";
|
||||
import { getExportStoreFromRuntime } from "../../../../../../lib/workflow/export-store";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ jobId: string; fileName: string }>;
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
const access = requireApiAccess(request);
|
||||
if (!access.ok) {
|
||||
return access.response;
|
||||
}
|
||||
|
||||
const { jobId, fileName } = await context.params;
|
||||
const contentType = CONTENT_TYPES[fileName];
|
||||
if (!contentType) {
|
||||
const exportStore = getExportStoreFromRuntime();
|
||||
const response = await exportStore.readJobExport(jobId, fileName);
|
||||
if (!response) {
|
||||
return NextResponse.json({ error: "Export file not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const path = join(getAppDataDir(), "exports", jobId, fileName);
|
||||
if (!existsSync(path)) {
|
||||
return NextResponse.json({ error: "Export file not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return new Response(readFileSync(path), {
|
||||
headers: {
|
||||
"content-type": contentType,
|
||||
"content-disposition": `attachment; filename="${fileName}"`,
|
||||
},
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
getArticleJob,
|
||||
getFactCard,
|
||||
saveOptimizedArticle,
|
||||
saveQaReport,
|
||||
updateArticleJob,
|
||||
} from "../../../../../lib/db/repositories";
|
||||
import { writeJobExports } from "../../../../../lib/workflow/exporter";
|
||||
import { requireApiAccess } from "../../../../../lib/api/auth";
|
||||
import { getRepositoryFromRuntime } from "../../../../../lib/db/repository";
|
||||
import { getExportStoreFromRuntime } from "../../../../../lib/workflow/export-store";
|
||||
import { runOptimizationWorkflow } from "../../../../../lib/workflow/orchestrator";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ jobId: string }>;
|
||||
}
|
||||
|
||||
export async function POST(_request: Request, context: RouteContext) {
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
const access = requireApiAccess(request);
|
||||
if (!access.ok) {
|
||||
return access.response;
|
||||
}
|
||||
|
||||
const { jobId } = await context.params;
|
||||
const job = getArticleJob(undefined, jobId);
|
||||
const repository = getRepositoryFromRuntime();
|
||||
const job = await repository.getArticleJob(jobId);
|
||||
if (!job) {
|
||||
return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const factCardRecord = getFactCard(undefined, jobId);
|
||||
const factCardRecord = await repository.getFactCard(jobId);
|
||||
if (!factCardRecord) {
|
||||
return NextResponse.json(
|
||||
{ error: "Confirm the fact card before optimizing" },
|
||||
@@ -39,22 +40,22 @@ export async function POST(_request: Request, context: RouteContext) {
|
||||
},
|
||||
factCard: factCardRecord,
|
||||
});
|
||||
const optimizedArticle = saveOptimizedArticle(undefined, jobId, result.article);
|
||||
const qaReport = saveQaReport(
|
||||
undefined,
|
||||
const optimizedArticle = await repository.saveOptimizedArticle(jobId, result.article);
|
||||
const qaReport = await repository.saveQaReport(
|
||||
jobId,
|
||||
optimizedArticle.revision ?? 1,
|
||||
result.qaReport,
|
||||
);
|
||||
const exportStore = getExportStoreFromRuntime();
|
||||
const exportPaths =
|
||||
qaReport.overall_status === "fail"
|
||||
? {}
|
||||
: await writeJobExports({
|
||||
: await exportStore.writeJobExports({
|
||||
jobId,
|
||||
article: optimizedArticle,
|
||||
qaReport,
|
||||
});
|
||||
updateArticleJob(undefined, jobId, {
|
||||
await repository.updateArticleJob(jobId, {
|
||||
status: qaReport.overall_status === "fail" ? "qa_failed" : "optimized",
|
||||
export_paths: exportPaths,
|
||||
});
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { createArticleJob } from "../../../lib/db/repositories";
|
||||
import { requireApiAccess } from "../../../lib/api/auth";
|
||||
import { getRepositoryFromRuntime } from "../../../lib/db/repository";
|
||||
import { extractCandidateFactCard } from "../../../lib/workflow/fact-extractor";
|
||||
import { normalizeInput } from "../../../lib/workflow/input-normalizer";
|
||||
import { normalizeInput, type RawArticleInput } from "../../../lib/workflow/input-normalizer";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const access = requireApiAccess(request);
|
||||
if (!access.ok) {
|
||||
return access.response;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await request.json();
|
||||
const payload = (await request.json()) as RawArticleInput;
|
||||
const normalized = normalizeInput(payload);
|
||||
const job = createArticleJob(undefined, {
|
||||
const repository = getRepositoryFromRuntime();
|
||||
const job = await repository.createArticleJob({
|
||||
source_title: normalized.articleInput.title,
|
||||
source_body: normalized.articleInput.body,
|
||||
image_inputs: normalized.articleInput.images,
|
||||
|
||||
@@ -104,6 +104,10 @@ h3 {
|
||||
color: #586174;
|
||||
}
|
||||
|
||||
.api-key-field {
|
||||
min-width: min(16rem, 100%);
|
||||
}
|
||||
|
||||
.workflow-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
+42
-5
@@ -26,6 +26,24 @@ const initialInput: ArticleInputPayload = {
|
||||
user_instructions: "",
|
||||
};
|
||||
|
||||
interface ApiErrorResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface CreateJobResponse extends ApiErrorResponse {
|
||||
job: { id: string };
|
||||
candidateFactCard: CandidateFactCard;
|
||||
}
|
||||
|
||||
interface ConfirmFactCardResponse extends ApiErrorResponse {
|
||||
factCard: CandidateFactCard;
|
||||
}
|
||||
|
||||
interface OptimizeJobResponse extends ApiErrorResponse {
|
||||
optimizedArticle: OptimizedArticle;
|
||||
qaReport: QaReport;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [input, setInput] = useState(initialInput);
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
@@ -35,6 +53,7 @@ export default function Home() {
|
||||
const [qaReport, setQaReport] = useState<QaReport | null>(null);
|
||||
const [busyAction, setBusyAction] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string>("");
|
||||
const [apiAccessKey, setApiAccessKey] = useState("");
|
||||
|
||||
const exportBlocked = useMemo(
|
||||
() => qaReport?.overall_status === "fail",
|
||||
@@ -51,10 +70,10 @@ export default function Home() {
|
||||
try {
|
||||
const response = await fetch("/api/jobs", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
headers: apiHeaders(apiAccessKey),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await response.json();
|
||||
const body = (await response.json()) as CreateJobResponse;
|
||||
if (!response.ok) throw new Error(body.error ?? "分析失败");
|
||||
setJobId(body.job.id);
|
||||
setFactCard(body.candidateFactCard);
|
||||
@@ -73,10 +92,10 @@ export default function Home() {
|
||||
try {
|
||||
const response = await fetch(`/api/jobs/${jobId}/confirm-fact-card`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
headers: apiHeaders(apiAccessKey),
|
||||
body: JSON.stringify(toConfirmedFactCard(factCard)),
|
||||
});
|
||||
const body = await response.json();
|
||||
const body = (await response.json()) as ConfirmFactCardResponse;
|
||||
if (!response.ok) throw new Error(body.error ?? "确认失败");
|
||||
setFactCard(body.factCard);
|
||||
setMessage("事实卡已确认。");
|
||||
@@ -94,8 +113,9 @@ export default function Home() {
|
||||
try {
|
||||
const response = await fetch(`/api/jobs/${jobId}/optimize`, {
|
||||
method: "POST",
|
||||
headers: apiHeaders(apiAccessKey),
|
||||
});
|
||||
const body = await response.json();
|
||||
const body = (await response.json()) as OptimizeJobResponse;
|
||||
if (!response.ok) throw new Error(body.error ?? "优化失败");
|
||||
setOptimizedArticle(body.optimizedArticle);
|
||||
setQaReport(body.qaReport);
|
||||
@@ -118,6 +138,15 @@ export default function Home() {
|
||||
<h1>GEO 智能文章优化器</h1>
|
||||
{message && <p>{message}</p>}
|
||||
</div>
|
||||
<label className="api-key-field">
|
||||
<span>访问密钥</span>
|
||||
<input
|
||||
autoComplete="off"
|
||||
onChange={(event) => setApiAccessKey(event.target.value)}
|
||||
type="password"
|
||||
value={apiAccessKey}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
disabled={!canOptimize || busyAction === "optimize"}
|
||||
onClick={optimize}
|
||||
@@ -149,3 +178,11 @@ export default function Home() {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function apiHeaders(apiAccessKey: string) {
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
if (apiAccessKey) {
|
||||
headers["x-api-key"] = apiAccessKey;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user