feat: add cloudflare workers deployment

This commit is contained in:
Codex
2026-06-21 23:43:02 +08:00
parent 2fc34979f4
commit 44e9e04533
35 changed files with 8343 additions and 121 deletions
+4
View File
@@ -0,0 +1,4 @@
NEXTJS_ENV=development
API_ACCESS_KEY=local-worker-dev-key
DEEPSEEK_API_KEY=
OPENAI_API_KEY=
+4 -2
View File
@@ -1,8 +1,10 @@
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4.1-mini
LLM_PROVIDER=deepseek
DEEPSEEK_API_KEY=
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-v4-pro
DEEPSEEK_THINKING=disabled
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4.1-mini
APP_DATA_DIR=./data
API_ACCESS_KEY=local-dev-key
API_AUTH_DISABLED=false
+5
View File
@@ -11,3 +11,8 @@ data/exports/
*.log
test-results/
playwright-report/
.open-next
.wrangler
.dev.vars
cloudflare-env.d.ts
tsconfig.tsbuildinfo
+70 -2
View File
@@ -25,6 +25,8 @@ DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-v4-pro
DEEPSEEK_THINKING=disabled
APP_DATA_DIR=./data
API_ACCESS_KEY=local-dev-key
API_AUTH_DISABLED=false
```
OpenAI-compatible fallback keys are also accepted:
@@ -37,6 +39,12 @@ OPENAI_MODEL=gpt-4.1-mini
When no API key is configured, deterministic local fallbacks keep the workflow
usable for tests and local review.
All API requests require the configured access key:
```text
x-api-key: <API_ACCESS_KEY>
```
## Commands
```bash
@@ -45,14 +53,74 @@ npm run build
npx playwright test
```
## Cloudflare Workers Deployment
Cloudflare deployment is manual. Pushing to Git does not deploy or hot-update
the production Worker.
Local development:
```bash
npm run dev
```
Cloudflare preview:
```bash
cp .dev.vars.example .dev.vars
npm run d1:migrate:local
npm run preview:worker
```
Create private staging resources:
```bash
npx wrangler d1 create geo-agent-article-optimizer-staging
npx wrangler r2 bucket create geo-agent-article-optimizer-staging
```
Create private production resources:
```bash
npx wrangler d1 create geo-agent-article-optimizer-production
npx wrangler r2 bucket create geo-agent-article-optimizer-production
```
After D1 creation, copy the returned database IDs into the matching
`wrangler.jsonc` environment entries. Keep R2 buckets private; do not add public
bucket domains.
Set secrets:
```bash
npx wrangler secret put API_ACCESS_KEY --env staging
npx wrangler secret put DEEPSEEK_API_KEY --env staging
npx wrangler secret put API_ACCESS_KEY --env production
npx wrangler secret put DEEPSEEK_API_KEY --env production
```
D1 schema changes only through migrations. Runtime code must not rebuild or
clear production tables. Apply migrations in this order:
```bash
npm run d1:migrate:local
npm run d1:migrate:staging
npm run deploy:worker:staging
npm run d1:migrate:production
npm run deploy:worker:production
```
## Exports
Generated files are written under:
Local generated files are written under:
```text
data/exports/<job_id>/
```
Cloudflare exports are written to the private R2 bucket bound as
`EXPORT_BUCKET` and served only through authenticated API routes.
Each passing or warning-only QA run can produce:
- `optimized.md`
@@ -64,7 +132,7 @@ Hard QA failures block export.
## MVP Limits
- Pasted text only; no direct `.docx` parsing.
- Local SQLite storage only.
- Local SQLite by default; Cloudflare deployments use D1.
- No account permissions or collaboration.
- No publishing platform APIs.
- No batch queue.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,216 @@
# Cloudflare Workers Deployment Design
## Goal
Convert the GEO Agent Article Optimizer into a Cloudflare Workers deployable app
using the official Cloudflare bindings path for persistence and private exports.
The deployed app must be a real production-shaped target, not only a static or
partially working preview.
## Current State
The app is a Next.js application with API routes. It currently stores data in a
local SQLite file through `better-sqlite3` and writes exports under
`data/exports/<jobId>/`. That is suitable for local MVP use, but it does not map
to Cloudflare Workers because production Workers should not depend on a mutable
local filesystem or native SQLite addons.
## Chosen Approach
Use Next.js on Cloudflare Workers through the OpenNext Cloudflare adapter and
Wrangler. Cloudflare production persistence will use:
- D1 binding `DB` for relational application data.
- Private R2 binding `EXPORT_BUCKET` for generated export objects.
The app will keep local Node development separate from Cloudflare deployment:
- `npm run dev` remains the local developer path.
- Local Node tests can continue using local SQLite and filesystem exports.
- Cloudflare preview/deploy scripts use OpenNext and Wrangler.
- Production code paths access D1 and R2 only through bindings, not REST APIs.
## Environment Separation
Local and online environments must be explicit.
- Local development uses `.env.local`, local SQLite, and local export files.
- Cloudflare preview uses Wrangler local bindings and Cloudflare-compatible
runtime behavior.
- Staging and production each get their own D1 database, R2 bucket, Worker name,
and secrets.
- Deployments are manual. No Git auto-deploy or automatic hot update workflow is
configured.
Recommended script split:
- `dev`: local Next.js development.
- `build`: regular Next.js build for local validation.
- `build:worker`: OpenNext Cloudflare build.
- `preview:worker`: Wrangler local preview.
- `deploy:worker:staging`: explicit staging deploy.
- `deploy:worker:production`: explicit production deploy.
## API Access Control
All `/api/*` routes are protected by default with an API access key.
- The expected secret is `API_ACCESS_KEY`.
- Clients send the key in `x-api-key`.
- Local development and tests may use an explicit test/dev bypass or a local
`.env.local` value.
- Production secrets are set through Wrangler secrets, not committed files.
- Missing or incorrect keys return `401`.
The first implementation can use a single shared key because the current app has
no account model. The design should leave room for a future account/session
layer without mixing that concern into the Cloudflare migration.
## Data Storage
### Repository Boundary
API routes should not call `better-sqlite3` directly. Introduce a repository
boundary for article jobs, brand templates, fact cards, optimized articles, and
QA reports.
Implementations:
- Local Node implementation: uses existing SQLite behavior for tests and local
development.
- Cloudflare implementation: uses D1 binding `DB` and async queries.
The route layer chooses the implementation based on runtime context. Online
Workers must fail clearly if required bindings are missing instead of silently
falling back to local storage.
### D1 Schema Management
D1 schema is managed only through migrations.
- Runtime code must not automatically create, rebuild, drop, or clear tables.
- Initial schema lives in a D1 migration file.
- Future schema changes use compatibility-first migrations, such as
`ALTER TABLE ... ADD COLUMN` when possible.
- If a change requires reshaping existing data, add a dedicated data migration
script or migration step with clear staging validation instructions.
- Before production deployment, migrations are applied and verified against
local/staging D1 first, then applied to production.
This replaces the current local `initializeSchema` runtime behavior for the
Cloudflare path. Local test setup may still initialize local SQLite test
databases because those are disposable test fixtures, not deployed production
state.
## Export Storage
Split export handling into rendering and storage.
Rendering remains runtime-agnostic:
- Markdown renderer.
- QA JSON renderer.
- DOCX renderer.
Storage becomes environment-specific:
- Local storage writes to `data/exports/<jobId>/`.
- Cloudflare storage writes objects to private R2 through `EXPORT_BUCKET`.
R2 object keys:
- `exports/<jobId>/optimized.md`
- `exports/<jobId>/optimized.docx`
- `exports/<jobId>/qa_report.json`
The R2 bucket is private. Do not configure a public bucket URL for app exports.
Downloads go through the authenticated Worker API, which reads the object from
R2 and returns it as an attachment.
## Request Flow
1. `POST /api/jobs` checks `x-api-key`, normalizes input, writes the job to the
active repository, and returns the candidate fact card.
2. `POST /api/jobs/:jobId/confirm-fact-card` checks the key, loads the job,
writes the brand template and confirmed fact card, and updates job status.
3. `POST /api/jobs/:jobId/optimize` checks the key, loads job and fact card,
runs the optimization workflow, saves article and QA report, and writes
export objects if QA does not fail.
4. `GET /api/jobs/:jobId/exports/:fileName` checks the key and returns the
object from local storage or private R2.
## Wrangler And Bindings
Use `wrangler.jsonc`.
Required production bindings:
- D1 database binding `DB`.
- R2 bucket binding `EXPORT_BUCKET`.
Required secrets:
- `API_ACCESS_KEY`.
- LLM provider secrets such as `DEEPSEEK_API_KEY` or `OPENAI_API_KEY`.
Recommended non-secret vars:
- `LLM_PROVIDER`.
- `DEEPSEEK_BASE_URL`.
- `DEEPSEEK_MODEL`.
- `DEEPSEEK_THINKING`.
- `OPENAI_MODEL`.
- `APP_RUNTIME=cloudflare` for clarity if needed by runtime selection.
Enable `nodejs_compat` because the app and dependencies use Node-compatible
packages, including document generation and the OpenAI client.
## Error Handling
- Missing API key: `401`.
- Missing Cloudflare binding in Worker runtime: `500` with a concise operational
error, without exposing secret values.
- Missing job or export object: `404`.
- Fact card not confirmed before optimization: `409`.
- D1/R2 operation failures: structured server errors and enough logging context
to identify the operation and job id.
## Testing And Verification
Tests should cover both local behavior and Cloudflare-specific behavior.
Local tests:
- Existing repository tests continue to cover SQLite behavior.
- Export renderer tests continue to cover Markdown, JSON, and DOCX output.
- API tests cover access-key enforcement.
Cloudflare-focused tests:
- D1 repository tests using local D1/Miniflare-compatible bindings or Wrangler.
- R2 export store tests for write, read, missing object, and content type.
- Runtime selection tests proving Cloudflare path uses bindings and does not
fall back to local files.
Verification commands:
- `npm test`
- `npm run build`
- `npm run build:worker`
- `npx wrangler deploy --dry-run`
Migration verification before production:
1. Apply D1 migrations locally.
2. Apply and verify against staging D1.
3. Deploy staging Worker and smoke test protected API calls and exports.
4. Apply migrations to production D1.
5. Deploy production Worker explicitly.
## Out Of Scope
- User accounts and per-user permissions.
- Public R2 asset hosting.
- Git-triggered auto-deploy.
- Replacing the existing LLM provider abstraction.
- Batch queues or background workflow orchestration.
+61
View File
@@ -0,0 +1,61 @@
CREATE TABLE IF NOT EXISTS brand_templates (
id TEXT PRIMARY KEY,
brand_name TEXT NOT NULL,
company_full_name TEXT NOT NULL,
company_short_names TEXT NOT NULL,
product_names TEXT NOT NULL,
target_industries TEXT NOT NULL,
target_audience TEXT NOT NULL,
verified_claims TEXT NOT NULL,
forbidden_claims TEXT NOT NULL,
tone_rules TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS article_jobs (
id TEXT PRIMARY KEY,
brand_template_id TEXT,
source_title TEXT NOT NULL,
source_body TEXT NOT NULL,
image_inputs TEXT NOT NULL,
publish_platform TEXT NOT NULL,
user_instructions TEXT NOT NULL,
status TEXT NOT NULL,
export_paths TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (brand_template_id) REFERENCES brand_templates(id)
);
CREATE TABLE IF NOT EXISTS fact_cards (
job_id TEXT PRIMARY KEY,
source TEXT NOT NULL,
fact_card TEXT NOT NULL,
confirmed_by_user INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (job_id) REFERENCES article_jobs(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS optimized_articles (
job_id TEXT NOT NULL,
revision INTEGER NOT NULL,
article TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (job_id, revision),
FOREIGN KEY (job_id) REFERENCES article_jobs(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS qa_reports (
job_id TEXT NOT NULL,
revision INTEGER NOT NULL,
report TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (job_id, revision),
FOREIGN KEY (job_id, revision)
REFERENCES optimized_articles(job_id, revision) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_article_jobs_updated_at
ON article_jobs(updated_at);
+3
View File
@@ -1,5 +1,8 @@
import type { NextConfig } from "next";
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
const nextConfig: NextConfig = {};
export default nextConfig;
initOpenNextCloudflareForDev();
+3
View File
@@ -0,0 +1,3 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
export default defineCloudflareConfig({});
+5372 -41
View File
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -7,10 +7,19 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"test:watch": "vitest"
"test:watch": "vitest",
"build:worker": "opennextjs-cloudflare build",
"preview:worker": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy:worker:staging": "opennextjs-cloudflare build && opennextjs-cloudflare deploy --env staging",
"deploy:worker:production": "opennextjs-cloudflare build && opennextjs-cloudflare deploy --env production",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
"d1:migrate:local": "wrangler d1 migrations apply geo-agent-article-optimizer-local --local",
"d1:migrate:staging": "wrangler d1 migrations apply geo-agent-article-optimizer-staging --env staging --remote",
"d1:migrate:production": "wrangler d1 migrations apply geo-agent-article-optimizer-production --env production --remote"
},
"private": true,
"dependencies": {
"@opennextjs/cloudflare": "^1.19.11",
"better-sqlite3": "^12.11.1",
"docx": "^9.7.1",
"nanoid": "^5.1.11",
@@ -21,6 +30,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260616.1",
"@playwright/test": "^1.61.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.9.3",
@@ -34,6 +44,7 @@
"prettier": "^3.8.4",
"tailwindcss": "^3.4.17",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
"vitest": "^4.1.9",
"wrangler": "^4.100.0"
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
webServer: {
command: "npm run dev",
command: "API_ACCESS_KEY=local-dev-key API_AUTH_DISABLED=false npm run dev",
url: "http://localhost:3000",
reuseExistingServer: true,
timeout: 120_000,
+2
View File
@@ -0,0 +1,2 @@
/_next/static/*
Cache-Control: public,max-age=31536000,immutable
+59 -10
View File
@@ -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;
}
+17 -16
View File
@@ -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,
});
+11 -4
View File
@@ -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,
+4
View File
@@ -104,6 +104,10 @@ h3 {
color: #586174;
}
.api-key-field {
min-width: min(16rem, 100%);
}
.workflow-grid {
display: grid;
gap: 1rem;
+42 -5
View File
@@ -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;
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, test } from "vitest";
import { requireApiAccess } from "../auth";
describe("requireApiAccess", () => {
test("allows local test requests when auth is explicitly disabled", () => {
const request = new Request("http://localhost/api/jobs");
const result = requireApiAccess(request, {
apiAccessKey: undefined,
authDisabled: true,
});
expect(result.ok).toBe(true);
});
test("rejects requests when the configured key is missing", () => {
const request = new Request("http://localhost/api/jobs");
const result = requireApiAccess(request, {
apiAccessKey: "secret",
authDisabled: false,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.response.status).toBe(401);
}
});
test("rejects requests with the wrong key", () => {
const request = new Request("http://localhost/api/jobs", {
headers: { "x-api-key": "wrong" },
});
const result = requireApiAccess(request, {
apiAccessKey: "secret",
authDisabled: false,
});
expect(result.ok).toBe(false);
});
test("allows requests with the correct key", () => {
const request = new Request("http://localhost/api/jobs", {
headers: { "x-api-key": "secret" },
});
const result = requireApiAccess(request, {
apiAccessKey: "secret",
authDisabled: false,
});
expect(result.ok).toBe(true);
});
});
+48
View File
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { getAppCloudflareEnv } from "../runtime/cloudflare";
interface ApiAccessOptions {
apiAccessKey?: string;
authDisabled?: boolean;
}
type ApiAccessResult =
| { ok: true }
| { ok: false; response: NextResponse<{ error: string }> };
export function requireApiAccess(
request: Request,
options: ApiAccessOptions = {
apiAccessKey: getConfiguredApiAccessKey(),
authDisabled: process.env.API_AUTH_DISABLED === "true",
},
): ApiAccessResult {
if (options.authDisabled) {
return { ok: true };
}
if (!options.apiAccessKey) {
return {
ok: false,
response: NextResponse.json(
{ error: "API access key is not configured" },
{ status: 401 },
),
};
}
const provided = request.headers.get("x-api-key");
if (provided !== options.apiAccessKey) {
return {
ok: false,
response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
};
}
return { ok: true };
}
function getConfiguredApiAccessKey() {
return getAppCloudflareEnv()?.API_ACCESS_KEY ?? process.env.API_ACCESS_KEY;
}
@@ -0,0 +1,65 @@
import { describe, expect, test, vi } from "vitest";
import { createD1Repository } from "../d1-repository";
describe("createD1Repository", () => {
test("creates an article job using D1 prepare and bind", async () => {
const run = vi.fn().mockResolvedValue({ success: true });
const bind = vi.fn().mockReturnValue({ run });
const prepare = vi.fn().mockReturnValue({ bind });
const db = { prepare } as unknown as D1Database;
const repository = createD1Repository(db);
const job = await repository.createArticleJob({
source_title: "Title",
source_body: "Body",
image_inputs: [],
publish_platform: "official_site",
user_instructions: "",
});
expect(job.id).toMatch(/^job_/);
expect(prepare).toHaveBeenCalledWith(expect.stringContaining("insert into article_jobs"));
expect(bind).toHaveBeenCalledWith(
job.id,
null,
"Title",
"Body",
"[]",
"official_site",
"",
"draft",
"{}",
job.created_at,
job.updated_at,
);
});
test("parses article job JSON fields returned from D1", async () => {
const first = vi.fn().mockResolvedValue({
id: "job_123",
brand_template_id: null,
source_title: "Title",
source_body: "Body",
image_inputs: "[]",
publish_platform: "official_site",
user_instructions: "",
status: "draft",
export_paths: "{}",
created_at: "2026-06-16T00:00:00.000Z",
updated_at: "2026-06-16T00:00:00.000Z",
});
const bind = vi.fn().mockReturnValue({ first });
const prepare = vi.fn().mockReturnValue({ bind });
const db = { prepare } as unknown as D1Database;
const repository = createD1Repository(db);
await expect(repository.getArticleJob("job_123")).resolves.toMatchObject({
id: "job_123",
image_inputs: [],
export_paths: {},
});
});
});
@@ -0,0 +1,23 @@
import { describe, expect, test } from "vitest";
import { getRepositoryFromRuntime } from "../repository";
describe("getRepositoryFromRuntime", () => {
test("returns local repository when APP_RUNTIME is not cloudflare", () => {
const repository = getRepositoryFromRuntime({
appRuntime: "local",
dbPath: ":memory:",
});
expect(repository).toBeDefined();
});
test("throws clearly for cloudflare runtime without D1 binding", () => {
expect(() =>
getRepositoryFromRuntime({
appRuntime: "cloudflare",
cloudflareEnv: {},
}),
).toThrow("Cloudflare D1 binding DB is required");
});
});
+39
View File
@@ -0,0 +1,39 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createSqliteRepository } from "../sqlite-repository";
describe("createSqliteRepository", () => {
let tempDir: string;
let dbPath: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "geo-repository-"));
dbPath = join(tempDir, "app.db");
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
test("creates and reads an article job through the async repository interface", async () => {
const repository = createSqliteRepository(dbPath);
const job = await repository.createArticleJob({
source_title: "Title",
source_body: "Body",
image_inputs: [],
publish_platform: "official_site",
user_instructions: "",
});
await expect(repository.getArticleJob(job.id)).resolves.toMatchObject({
id: job.id,
source_title: "Title",
export_paths: {},
});
});
});
+303
View File
@@ -0,0 +1,303 @@
import { nanoid } from "nanoid";
import type {
ConfirmedFactCard,
ImageInput,
OptimizedArticle,
PublishPlatform,
QaReport,
} from "../domain/types";
import type { AppRepository } from "./repository";
import type { ArticleJob, BrandTemplate } from "./repositories";
type JsonObject = Record<string, unknown>;
interface BrandTemplateRow {
id: string;
brand_name: string;
company_full_name: string;
company_short_names: string;
product_names: string;
target_industries: string;
target_audience: string;
verified_claims: string;
forbidden_claims: string;
tone_rules: string;
created_at: string;
updated_at: string;
}
interface ArticleJobRow {
id: string;
brand_template_id: string | null;
source_title: string;
source_body: string;
image_inputs: string;
publish_platform: PublishPlatform;
user_instructions: string;
status: string;
export_paths: string;
created_at: string;
updated_at: string;
}
interface FactCardRow {
job_id: string;
fact_card: string;
}
interface OptimizedArticleRow {
job_id: string;
revision: number;
article: string;
}
interface QaReportRow {
job_id: string;
revision: number;
report: string;
}
function nowIso() {
return new Date().toISOString();
}
function serialize(value: unknown) {
return JSON.stringify(value);
}
function parseJson<T>(value: string): T {
return JSON.parse(value) as T;
}
function toBrandTemplate(row: BrandTemplateRow): BrandTemplate {
return {
...row,
company_short_names: parseJson<string[]>(row.company_short_names),
product_names: parseJson<string[]>(row.product_names),
target_industries: parseJson<string[]>(row.target_industries),
target_audience: parseJson<string[]>(row.target_audience),
verified_claims: parseJson<string[]>(row.verified_claims),
forbidden_claims: parseJson<string[]>(row.forbidden_claims),
tone_rules: parseJson<JsonObject>(row.tone_rules),
};
}
function toArticleJob(row: ArticleJobRow): ArticleJob {
return {
...row,
image_inputs: parseJson<ImageInput[]>(row.image_inputs),
export_paths: parseJson<Record<string, string>>(row.export_paths),
};
}
export function createD1Repository(db: D1Database): AppRepository {
return {
async createBrandTemplate(input) {
const createdAt = nowIso();
const template: BrandTemplate = {
id: `brand_${nanoid(10)}`,
...input,
created_at: createdAt,
updated_at: createdAt,
};
await db
.prepare(
`insert into brand_templates (
id, brand_name, company_full_name, company_short_names, product_names,
target_industries, target_audience, verified_claims, forbidden_claims,
tone_rules, created_at, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
template.id,
template.brand_name,
template.company_full_name,
serialize(template.company_short_names),
serialize(template.product_names),
serialize(template.target_industries),
serialize(template.target_audience),
serialize(template.verified_claims),
serialize(template.forbidden_claims),
serialize(template.tone_rules),
template.created_at,
template.updated_at,
)
.run();
return template;
},
async listBrandTemplates() {
const result = await db
.prepare("select * from brand_templates order by updated_at desc")
.all<BrandTemplateRow>();
return result.results.map(toBrandTemplate);
},
async getBrandTemplate(id) {
const row = await db
.prepare("select * from brand_templates where id = ?")
.bind(id)
.first<BrandTemplateRow>();
return row ? toBrandTemplate(row) : null;
},
async createArticleJob(input) {
const createdAt = nowIso();
const job: ArticleJob = {
id: `job_${nanoid(10)}`,
brand_template_id: input.brand_template_id ?? null,
source_title: input.source_title,
source_body: input.source_body,
image_inputs: input.image_inputs,
publish_platform: input.publish_platform,
user_instructions: input.user_instructions,
status: "draft",
export_paths: {},
created_at: createdAt,
updated_at: createdAt,
};
await db
.prepare(
`insert into article_jobs (
id, brand_template_id, source_title, source_body, image_inputs,
publish_platform, user_instructions, status, export_paths, created_at, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
job.id,
job.brand_template_id,
job.source_title,
job.source_body,
serialize(job.image_inputs),
job.publish_platform,
job.user_instructions,
job.status,
serialize(job.export_paths),
job.created_at,
job.updated_at,
)
.run();
return job;
},
async getArticleJob(id) {
const row = await db
.prepare("select * from article_jobs where id = ?")
.bind(id)
.first<ArticleJobRow>();
return row ? toArticleJob(row) : null;
},
async updateArticleJob(id, changes) {
const existing = await this.getArticleJob(id);
if (!existing) return null;
const updated = {
brand_template_id: changes.brand_template_id ?? existing.brand_template_id,
status: changes.status ?? existing.status,
export_paths: changes.export_paths ?? existing.export_paths,
updated_at: nowIso(),
};
await db
.prepare(
`update article_jobs set
brand_template_id = ?,
status = ?,
export_paths = ?,
updated_at = ?
where id = ?`,
)
.bind(
updated.brand_template_id,
updated.status,
serialize(updated.export_paths),
updated.updated_at,
id,
)
.run();
return this.getArticleJob(id);
},
async saveFactCard(jobId, factCard) {
const timestamp = nowIso();
await db
.prepare(
`insert into fact_cards (
job_id, source, fact_card, confirmed_by_user, created_at, updated_at
) values (?, ?, ?, ?, ?, ?)
on conflict(job_id) do update set
fact_card = excluded.fact_card,
confirmed_by_user = excluded.confirmed_by_user,
updated_at = excluded.updated_at`,
)
.bind(
jobId,
"auto_extract_then_user_confirmed",
serialize(factCard),
factCard.confirmed_by_user ? 1 : 0,
timestamp,
timestamp,
)
.run();
return { job_id: jobId, ...factCard };
},
async getFactCard(jobId) {
const row = await db
.prepare("select job_id, fact_card from fact_cards where job_id = ?")
.bind(jobId)
.first<FactCardRow>();
return row
? { job_id: row.job_id, ...parseJson<ConfirmedFactCard>(row.fact_card) }
: null;
},
async saveOptimizedArticle(jobId, article) {
const latest = await this.getLatestOptimizedArticle(jobId);
const revision = (latest?.revision ?? 0) + 1;
const saved = { ...article, job_id: jobId, revision };
await db
.prepare(
`insert into optimized_articles (job_id, revision, article, created_at)
values (?, ?, ?, ?)`,
)
.bind(jobId, revision, serialize(saved), nowIso())
.run();
return saved;
},
async getLatestOptimizedArticle(jobId) {
const row = await db
.prepare(
`select job_id, revision, article
from optimized_articles
where job_id = ?
order by revision desc
limit 1`,
)
.bind(jobId)
.first<OptimizedArticleRow>();
return row ? parseJson<OptimizedArticle>(row.article) : null;
},
async saveQaReport(jobId, revision, report) {
const saved = { ...report, job_id: jobId, revision };
await db
.prepare(
`insert into qa_reports (job_id, revision, report, created_at)
values (?, ?, ?, ?)
on conflict(job_id, revision) do update set report = excluded.report`,
)
.bind(jobId, revision, serialize(saved), nowIso())
.run();
return saved;
},
async getLatestQaReport(jobId) {
const row = await db
.prepare(
`select job_id, revision, report
from qa_reports
where job_id = ?
order by revision desc
limit 1`,
)
.bind(jobId)
.first<QaReportRow>();
return row ? parseJson<QaReport>(row.report) : null;
},
};
}
+52
View File
@@ -0,0 +1,52 @@
import type { ConfirmedFactCard, OptimizedArticle, QaReport } from "../domain/types";
import type {
ArticleJob,
BrandTemplate,
NewArticleJob,
NewBrandTemplate,
} from "./repositories";
import { getAppCloudflareEnv, type AppCloudflareEnv } from "../runtime/cloudflare";
import { createD1Repository } from "./d1-repository";
import { createSqliteRepository } from "./sqlite-repository";
export interface AppRepository {
createBrandTemplate(input: NewBrandTemplate): Promise<BrandTemplate>;
listBrandTemplates(): Promise<BrandTemplate[]>;
getBrandTemplate(id: string): Promise<BrandTemplate | null>;
createArticleJob(input: NewArticleJob): Promise<ArticleJob>;
getArticleJob(id: string): Promise<ArticleJob | null>;
updateArticleJob(
id: string,
changes: Partial<Pick<ArticleJob, "brand_template_id" | "status" | "export_paths">>,
): Promise<ArticleJob | null>;
saveFactCard(
jobId: string,
factCard: ConfirmedFactCard,
): Promise<{ job_id: string } & ConfirmedFactCard>;
getFactCard(jobId: string): Promise<({ job_id: string } & ConfirmedFactCard) | null>;
saveOptimizedArticle(jobId: string, article: OptimizedArticle): Promise<OptimizedArticle>;
getLatestOptimizedArticle(jobId: string): Promise<OptimizedArticle | null>;
saveQaReport(jobId: string, revision: number, report: QaReport): Promise<QaReport>;
getLatestQaReport(jobId: string): Promise<QaReport | null>;
}
interface RuntimeRepositoryOptions {
appRuntime?: string;
dbPath?: string;
cloudflareEnv?: AppCloudflareEnv;
}
export function getRepositoryFromRuntime(
options: RuntimeRepositoryOptions = {},
): AppRepository {
const appRuntime = options.appRuntime ?? process.env.APP_RUNTIME;
if (appRuntime === "cloudflare") {
const env = options.cloudflareEnv ?? getAppCloudflareEnv();
if (!env?.DB) {
throw new Error("Cloudflare D1 binding DB is required");
}
return createD1Repository(env.DB);
}
return createSqliteRepository(options.dbPath);
}
+63
View File
@@ -0,0 +1,63 @@
import type { ConfirmedFactCard, OptimizedArticle, QaReport } from "../domain/types";
import type { AppRepository } from "./repository";
import {
createArticleJob,
createBrandTemplate,
getArticleJob,
getBrandTemplate,
getFactCard,
getLatestOptimizedArticle,
getLatestQaReport,
listBrandTemplates,
saveFactCard,
saveOptimizedArticle,
saveQaReport,
updateArticleJob,
type ArticleJob,
type NewArticleJob,
type NewBrandTemplate,
} from "./repositories";
export function createSqliteRepository(dbPath?: string): AppRepository {
return {
createBrandTemplate(input: NewBrandTemplate) {
return Promise.resolve(createBrandTemplate(dbPath, input));
},
listBrandTemplates() {
return Promise.resolve(listBrandTemplates(dbPath));
},
getBrandTemplate(id: string) {
return Promise.resolve(getBrandTemplate(dbPath, id));
},
createArticleJob(input: NewArticleJob) {
return Promise.resolve(createArticleJob(dbPath, input));
},
getArticleJob(id: string) {
return Promise.resolve(getArticleJob(dbPath, id));
},
updateArticleJob(
id: string,
changes: Partial<Pick<ArticleJob, "brand_template_id" | "status" | "export_paths">>,
) {
return Promise.resolve(updateArticleJob(dbPath, id, changes));
},
saveFactCard(jobId: string, factCard: ConfirmedFactCard) {
return Promise.resolve(saveFactCard(dbPath, jobId, factCard));
},
getFactCard(jobId: string) {
return Promise.resolve(getFactCard(dbPath, jobId));
},
saveOptimizedArticle(jobId: string, article: OptimizedArticle) {
return Promise.resolve(saveOptimizedArticle(dbPath, jobId, article));
},
getLatestOptimizedArticle(jobId: string) {
return Promise.resolve(getLatestOptimizedArticle(dbPath, jobId));
},
saveQaReport(jobId: string, revision: number, report: QaReport) {
return Promise.resolve(saveQaReport(dbPath, jobId, revision, report));
},
getLatestQaReport(jobId: string) {
return Promise.resolve(getLatestQaReport(dbPath, jobId));
},
};
}
+15
View File
@@ -0,0 +1,15 @@
import { getCloudflareContext } from "@opennextjs/cloudflare";
export interface AppCloudflareEnv {
DB?: D1Database;
EXPORT_BUCKET?: R2Bucket;
API_ACCESS_KEY?: string;
}
export function getAppCloudflareEnv(): AppCloudflareEnv | null {
if (process.env.APP_RUNTIME !== "cloudflare") {
return null;
}
return getCloudflareContext().env as AppCloudflareEnv;
}
@@ -0,0 +1,70 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { OptimizedArticle, QaReport } from "../../domain/types";
import { createLocalExportStore, createR2ExportStore } from "../export-store";
const article: OptimizedArticle = {
title: "Example",
summary: "Summary",
body_markdown: "Body",
image_suggestions: [],
changed_sections: [],
requires_user_confirmation: [],
revision: 1,
};
const report: QaReport = {
overall_status: "pass",
checks: [],
};
describe("export stores", () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "geo-export-store-"));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
test("local store writes and reads exports", async () => {
const store = createLocalExportStore(tempDir);
const paths = await store.writeJobExports({
jobId: "job_123",
article,
qaReport: report,
});
expect(paths.markdown).toContain("optimized.md");
const file = await store.readJobExport("job_123", "optimized.md");
expect(await file?.text()).toContain("# Example");
});
test("R2 store writes private export objects through binding", async () => {
const put = vi.fn().mockResolvedValue(undefined);
const bucket = { put } as unknown as R2Bucket;
const store = createR2ExportStore(bucket);
const paths = await store.writeJobExports({
jobId: "job_123",
article,
qaReport: report,
});
expect(paths.markdown).toBe("r2://exports/job_123/optimized.md");
expect(put).toHaveBeenCalledWith(
"exports/job_123/optimized.md",
expect.any(String),
expect.objectContaining({
httpMetadata: { contentType: "text/markdown; charset=utf-8" },
}),
);
});
});
+3 -2
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ConfirmedFactCard } from "../../domain/types";
import { optimizeArticle } from "../article-optimizer";
import { extractCandidateFactCard } from "../fact-extractor";
import { normalizeInput } from "../input-normalizer";
@@ -7,7 +8,7 @@ import { inspectQuality } from "../quality-inspector";
import { runOptimizationWorkflow } from "../orchestrator";
import { rewriteFailedSections } from "../targeted-rewriter";
const confirmedFactCard = {
const confirmedFactCard: ConfirmedFactCard = {
company_full_name: "Example Technology Co., Ltd.",
company_short_names: ["Example Tech"],
brand_names: ["Example"],
@@ -21,7 +22,7 @@ const confirmedFactCard = {
uncertain_items: [],
is_ready_for_optimization: true,
confirmed_by_user: true,
} as const;
};
describe("workflow nodes", () => {
it("normalizes input whitespace and image lines", () => {
+115
View File
@@ -0,0 +1,115 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getAppDataDir } from "../db/connection";
import type { OptimizedArticle, QaReport } from "../domain/types";
import { getAppCloudflareEnv } from "../runtime/cloudflare";
import {
renderOptimizedDocx,
renderOptimizedMarkdown,
renderQaReportJson,
} from "./exporter";
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",
};
export interface WriteJobExportsInput {
jobId: string;
article: OptimizedArticle;
qaReport: QaReport;
}
export interface ExportStore {
writeJobExports(input: WriteJobExportsInput): Promise<Record<string, string>>;
readJobExport(jobId: string, fileName: string): Promise<Response | null>;
}
export function createLocalExportStore(dataDir = getAppDataDir()): ExportStore {
return {
async writeJobExports({ jobId, article, qaReport }) {
const exportDir = join(dataDir, "exports", jobId);
mkdirSync(exportDir, { recursive: true });
const markdown = join(exportDir, "optimized.md");
const docx = join(exportDir, "optimized.docx");
const qaJson = join(exportDir, "qa_report.json");
writeFileSync(markdown, renderOptimizedMarkdown(article), "utf8");
writeFileSync(qaJson, renderQaReportJson(qaReport), "utf8");
writeFileSync(docx, await renderOptimizedDocx(article));
return { markdown, docx, qaJson };
},
async readJobExport(jobId, fileName) {
const contentType = CONTENT_TYPES[fileName];
if (!contentType) return null;
const path = join(dataDir, "exports", jobId, fileName);
if (!existsSync(path)) return null;
return new Response(readFileSync(path), {
headers: {
"content-type": contentType,
"content-disposition": `attachment; filename="${fileName}"`,
},
});
},
};
}
export function createR2ExportStore(bucket: R2Bucket): ExportStore {
return {
async writeJobExports({ jobId, article, qaReport }) {
const prefix = `exports/${jobId}`;
const markdownKey = `${prefix}/optimized.md`;
const docxKey = `${prefix}/optimized.docx`;
const qaJsonKey = `${prefix}/qa_report.json`;
await bucket.put(markdownKey, renderOptimizedMarkdown(article), {
httpMetadata: { contentType: CONTENT_TYPES["optimized.md"] },
});
await bucket.put(qaJsonKey, renderQaReportJson(qaReport), {
httpMetadata: { contentType: CONTENT_TYPES["qa_report.json"] },
});
await bucket.put(docxKey, await renderOptimizedDocx(article), {
httpMetadata: { contentType: CONTENT_TYPES["optimized.docx"] },
});
return {
markdown: `r2://${markdownKey}`,
docx: `r2://${docxKey}`,
qaJson: `r2://${qaJsonKey}`,
};
},
async readJobExport(jobId, fileName) {
const contentType = CONTENT_TYPES[fileName];
if (!contentType) return null;
const object = await bucket.get(`exports/${jobId}/${fileName}`);
if (!object) return null;
return new Response(object.body, {
headers: {
"content-type": object.httpMetadata?.contentType ?? contentType,
"content-disposition": `attachment; filename="${fileName}"`,
},
});
},
};
}
export function getExportStoreFromRuntime(): ExportStore {
if (process.env.APP_RUNTIME === "cloudflare") {
const env = getAppCloudflareEnv();
if (!env?.EXPORT_BUCKET) {
throw new Error("Cloudflare R2 binding EXPORT_BUCKET is required");
}
return createR2ExportStore(env.EXPORT_BUCKET);
}
return createLocalExportStore();
}
+1
View File
@@ -3,6 +3,7 @@ import { expect, test } from "@playwright/test";
test("中文界面可以生成优化文章和导出链接", async ({ page }) => {
await page.goto("/");
await page.getByLabel("访问密钥").fill("local-dev-key");
await page.getByLabel("标题").fill("Example Technology Co., Ltd. GEO 指南");
await page
.getByLabel("正文")
+1
View File
@@ -10,6 +10,7 @@
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"types": ["node", "@cloudflare/workers-types"],
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
+44
View File
@@ -0,0 +1,44 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "geo-agent-article-optimizer",
"compatibility_date": "2026-06-16",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"observability": {
"enabled": true,
"head_sampling_rate": 0.1
},
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "geo-agent-article-optimizer"
}
],
"vars": {
"APP_RUNTIME": "cloudflare",
"LLM_PROVIDER": "deepseek",
"DEEPSEEK_BASE_URL": "https://api.deepseek.com",
"DEEPSEEK_MODEL": "deepseek-v4-pro",
"DEEPSEEK_THINKING": "disabled",
"OPENAI_MODEL": "gpt-4.1-mini"
},
"d1_databases": [
{
"binding": "DB",
"database_name": "geo-agent-article-optimizer-local",
"database_id": "00000000-0000-0000-0000-000000000001",
"migrations_dir": "migrations"
}
],
"r2_buckets": [
{
"binding": "EXPORT_BUCKET",
"bucket_name": "geo-agent-article-optimizer-local"
}
],
"env": {}
}