feat: add cloudflare workers deployment
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
NEXTJS_ENV=development
|
||||||
|
API_ACCESS_KEY=local-worker-dev-key
|
||||||
|
DEEPSEEK_API_KEY=
|
||||||
|
OPENAI_API_KEY=
|
||||||
+4
-2
@@ -1,8 +1,10 @@
|
|||||||
OPENAI_API_KEY=
|
|
||||||
OPENAI_MODEL=gpt-4.1-mini
|
|
||||||
LLM_PROVIDER=deepseek
|
LLM_PROVIDER=deepseek
|
||||||
DEEPSEEK_API_KEY=
|
DEEPSEEK_API_KEY=
|
||||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||||
DEEPSEEK_MODEL=deepseek-v4-pro
|
DEEPSEEK_MODEL=deepseek-v4-pro
|
||||||
DEEPSEEK_THINKING=disabled
|
DEEPSEEK_THINKING=disabled
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_MODEL=gpt-4.1-mini
|
||||||
APP_DATA_DIR=./data
|
APP_DATA_DIR=./data
|
||||||
|
API_ACCESS_KEY=local-dev-key
|
||||||
|
API_AUTH_DISABLED=false
|
||||||
|
|||||||
@@ -11,3 +11,8 @@ data/exports/
|
|||||||
*.log
|
*.log
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
.open-next
|
||||||
|
.wrangler
|
||||||
|
.dev.vars
|
||||||
|
cloudflare-env.d.ts
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ DEEPSEEK_BASE_URL=https://api.deepseek.com
|
|||||||
DEEPSEEK_MODEL=deepseek-v4-pro
|
DEEPSEEK_MODEL=deepseek-v4-pro
|
||||||
DEEPSEEK_THINKING=disabled
|
DEEPSEEK_THINKING=disabled
|
||||||
APP_DATA_DIR=./data
|
APP_DATA_DIR=./data
|
||||||
|
API_ACCESS_KEY=local-dev-key
|
||||||
|
API_AUTH_DISABLED=false
|
||||||
```
|
```
|
||||||
|
|
||||||
OpenAI-compatible fallback keys are also accepted:
|
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
|
When no API key is configured, deterministic local fallbacks keep the workflow
|
||||||
usable for tests and local review.
|
usable for tests and local review.
|
||||||
|
|
||||||
|
All API requests require the configured access key:
|
||||||
|
|
||||||
|
```text
|
||||||
|
x-api-key: <API_ACCESS_KEY>
|
||||||
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -45,14 +53,74 @@ npm run build
|
|||||||
npx playwright test
|
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
|
## Exports
|
||||||
|
|
||||||
Generated files are written under:
|
Local generated files are written under:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
data/exports/<job_id>/
|
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:
|
Each passing or warning-only QA run can produce:
|
||||||
|
|
||||||
- `optimized.md`
|
- `optimized.md`
|
||||||
@@ -64,7 +132,7 @@ Hard QA failures block export.
|
|||||||
## MVP Limits
|
## MVP Limits
|
||||||
|
|
||||||
- Pasted text only; no direct `.docx` parsing.
|
- 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 account permissions or collaboration.
|
||||||
- No publishing platform APIs.
|
- No publishing platform APIs.
|
||||||
- No batch queue.
|
- 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.
|
||||||
@@ -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);
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {};
|
const nextConfig: NextConfig = {};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
||||||
|
initOpenNextCloudflareForDev();
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
|
||||||
|
|
||||||
|
export default defineCloudflareConfig({});
|
||||||
Generated
+5372
-41
File diff suppressed because it is too large
Load Diff
+13
-2
@@ -7,10 +7,19 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"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,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@opennextjs/cloudflare": "^1.19.11",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"docx": "^9.7.1",
|
"docx": "^9.7.1",
|
||||||
"nanoid": "^5.1.11",
|
"nanoid": "^5.1.11",
|
||||||
@@ -21,6 +30,7 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20260616.1",
|
||||||
"@playwright/test": "^1.61.0",
|
"@playwright/test": "^1.61.0",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
@@ -34,6 +44,7 @@
|
|||||||
"prettier": "^3.8.4",
|
"prettier": "^3.8.4",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.9",
|
||||||
|
"wrangler": "^4.100.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { defineConfig, devices } from "@playwright/test";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e",
|
testDir: "./tests/e2e",
|
||||||
webServer: {
|
webServer: {
|
||||||
command: "npm run dev",
|
command: "API_ACCESS_KEY=local-dev-key API_AUTH_DISABLED=false npm run dev",
|
||||||
url: "http://localhost:3000",
|
url: "http://localhost:3000",
|
||||||
reuseExistingServer: true,
|
reuseExistingServer: true,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/_next/static/*
|
||||||
|
Cache-Control: public,max-age=31536000,immutable
|
||||||
@@ -25,20 +25,51 @@ const validFactCard = {
|
|||||||
confirmed_by_user: true,
|
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", () => {
|
describe("job API routes", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
const originalDataDir = process.env.APP_DATA_DIR;
|
const originalDataDir = process.env.APP_DATA_DIR;
|
||||||
|
const originalApiKey = process.env.API_ACCESS_KEY;
|
||||||
|
const originalAuthDisabled = process.env.API_AUTH_DISABLED;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tempDir = mkdtempSync(join(tmpdir(), "geo-agent-api-"));
|
tempDir = mkdtempSync(join(tmpdir(), "geo-agent-api-"));
|
||||||
process.env.APP_DATA_DIR = tempDir;
|
process.env.APP_DATA_DIR = tempDir;
|
||||||
|
process.env.API_ACCESS_KEY = "test-key";
|
||||||
|
process.env.API_AUTH_DISABLED = "false";
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env.APP_DATA_DIR = originalDataDir;
|
process.env.APP_DATA_DIR = originalDataDir;
|
||||||
|
process.env.API_ACCESS_KEY = originalApiKey;
|
||||||
|
process.env.API_AUTH_DISABLED = originalAuthDisabled;
|
||||||
rmSync(tempDir, { recursive: true, force: true });
|
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 () => {
|
it("validates input, creates a job, and returns a candidate fact card", async () => {
|
||||||
const response = await createJob(
|
const response = await createJob(
|
||||||
request({
|
request({
|
||||||
@@ -49,7 +80,7 @@ describe("job API routes", () => {
|
|||||||
user_instructions: "Keep factual",
|
user_instructions: "Keep factual",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const body = await response.json();
|
const body = (await response.json()) as CreateJobResponse;
|
||||||
|
|
||||||
expect(response.status).toBe(201);
|
expect(response.status).toBe(201);
|
||||||
expect(body.job.id).toMatch(/^job_/);
|
expect(body.job.id).toMatch(/^job_/);
|
||||||
@@ -66,7 +97,7 @@ describe("job API routes", () => {
|
|||||||
uncertain_items: ["Need company confirmation"],
|
uncertain_items: ["Need company confirmation"],
|
||||||
is_ready_for_optimization: false,
|
is_ready_for_optimization: false,
|
||||||
}),
|
}),
|
||||||
params({ jobId: job.id }),
|
params<{ jobId: string }>({ jobId: job.id }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
@@ -74,17 +105,26 @@ describe("job API routes", () => {
|
|||||||
|
|
||||||
it("rejects optimize requests for jobs without confirmed fact cards", async () => {
|
it("rejects optimize requests for jobs without confirmed fact cards", async () => {
|
||||||
const { job } = await createJobFixture();
|
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);
|
expect(response.status).toBe(409);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns optimized article and QA report for successful optimization", async () => {
|
it("returns optimized article and QA report for successful optimization", async () => {
|
||||||
const { job } = await createJobFixture();
|
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 response = await optimizeJob(
|
||||||
const body = await response.json();
|
request({}),
|
||||||
|
params<{ jobId: string }>({ jobId: job.id }),
|
||||||
|
);
|
||||||
|
const body = (await response.json()) as OptimizeJobResponse;
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(body.optimizedArticle.title).toContain("GEO optimization");
|
expect(body.optimizedArticle.title).toContain("GEO optimization");
|
||||||
@@ -99,7 +139,10 @@ describe("job API routes", () => {
|
|||||||
|
|
||||||
const response = await downloadExport(
|
const response = await downloadExport(
|
||||||
request({}),
|
request({}),
|
||||||
params({ jobId: job.id, fileName: "unknown.txt" }),
|
params<{ jobId: string; fileName: string }>({
|
||||||
|
jobId: job.id,
|
||||||
|
fileName: "unknown.txt",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
expect(response.status).toBe(404);
|
||||||
@@ -119,14 +162,20 @@ async function createJobFixture() {
|
|||||||
return response.json() as Promise<{ job: { id: string } }>;
|
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", {
|
return new Request("http://localhost/api/jobs", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
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) };
|
return { params: Promise.resolve(values) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import {
|
import { requireApiAccess } from "../../../../../lib/api/auth";
|
||||||
createBrandTemplate,
|
import { getRepositoryFromRuntime } from "../../../../../lib/db/repository";
|
||||||
getArticleJob,
|
|
||||||
saveFactCard,
|
|
||||||
updateArticleJob,
|
|
||||||
} from "../../../../../lib/db/repositories";
|
|
||||||
import { confirmedFactCardSchema } from "../../../../../lib/domain/validation";
|
import { confirmedFactCardSchema } from "../../../../../lib/domain/validation";
|
||||||
|
|
||||||
interface RouteContext {
|
interface RouteContext {
|
||||||
@@ -13,16 +9,25 @@ interface RouteContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 { jobId } = await context.params;
|
||||||
const job = getArticleJob(undefined, jobId);
|
const repository = getRepositoryFromRuntime();
|
||||||
|
const job = await repository.getArticleJob(jobId);
|
||||||
if (!job) {
|
if (!job) {
|
||||||
return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const factCard = confirmedFactCardSchema.parse(await request.json());
|
const factCard = confirmedFactCardSchema.parse(await request.json());
|
||||||
const brandTemplate = createBrandTemplate(undefined, {
|
const brandTemplate = await repository.createBrandTemplate({
|
||||||
brand_name: factCard.brand_names[0] ?? factCard.company_short_names[0] ?? factCard.company_full_name,
|
brand_name:
|
||||||
|
factCard.brand_names[0] ??
|
||||||
|
factCard.company_short_names[0] ??
|
||||||
|
factCard.company_full_name,
|
||||||
company_full_name: factCard.company_full_name,
|
company_full_name: factCard.company_full_name,
|
||||||
company_short_names: factCard.company_short_names,
|
company_short_names: factCard.company_short_names,
|
||||||
product_names: factCard.product_names,
|
product_names: factCard.product_names,
|
||||||
@@ -35,8 +40,8 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
media_article: "objective third-party voice",
|
media_article: "objective third-party voice",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const savedFactCard = saveFactCard(undefined, jobId, factCard);
|
const savedFactCard = await repository.saveFactCard(jobId, factCard);
|
||||||
updateArticleJob(undefined, jobId, {
|
await repository.updateArticleJob(jobId, {
|
||||||
brand_template_id: brandTemplate.id,
|
brand_template_id: brandTemplate.id,
|
||||||
status: "fact_confirmed",
|
status: "fact_confirmed",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,37 +1,24 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { getAppDataDir } from "../../../../../../lib/db/connection";
|
import { requireApiAccess } from "../../../../../../lib/api/auth";
|
||||||
|
import { getExportStoreFromRuntime } from "../../../../../../lib/workflow/export-store";
|
||||||
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",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface RouteContext {
|
interface RouteContext {
|
||||||
params: Promise<{ jobId: string; fileName: string }>;
|
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 { jobId, fileName } = await context.params;
|
||||||
const contentType = CONTENT_TYPES[fileName];
|
const exportStore = getExportStoreFromRuntime();
|
||||||
if (!contentType) {
|
const response = await exportStore.readJobExport(jobId, fileName);
|
||||||
|
if (!response) {
|
||||||
return NextResponse.json({ error: "Export file not found" }, { status: 404 });
|
return NextResponse.json({ error: "Export file not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = join(getAppDataDir(), "exports", jobId, fileName);
|
return response;
|
||||||
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}"`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import {
|
import { requireApiAccess } from "../../../../../lib/api/auth";
|
||||||
getArticleJob,
|
import { getRepositoryFromRuntime } from "../../../../../lib/db/repository";
|
||||||
getFactCard,
|
import { getExportStoreFromRuntime } from "../../../../../lib/workflow/export-store";
|
||||||
saveOptimizedArticle,
|
|
||||||
saveQaReport,
|
|
||||||
updateArticleJob,
|
|
||||||
} from "../../../../../lib/db/repositories";
|
|
||||||
import { writeJobExports } from "../../../../../lib/workflow/exporter";
|
|
||||||
import { runOptimizationWorkflow } from "../../../../../lib/workflow/orchestrator";
|
import { runOptimizationWorkflow } from "../../../../../lib/workflow/orchestrator";
|
||||||
|
|
||||||
interface RouteContext {
|
interface RouteContext {
|
||||||
params: Promise<{ jobId: string }>;
|
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 { jobId } = await context.params;
|
||||||
const job = getArticleJob(undefined, jobId);
|
const repository = getRepositoryFromRuntime();
|
||||||
|
const job = await repository.getArticleJob(jobId);
|
||||||
if (!job) {
|
if (!job) {
|
||||||
return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const factCardRecord = getFactCard(undefined, jobId);
|
const factCardRecord = await repository.getFactCard(jobId);
|
||||||
if (!factCardRecord) {
|
if (!factCardRecord) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Confirm the fact card before optimizing" },
|
{ error: "Confirm the fact card before optimizing" },
|
||||||
@@ -39,22 +40,22 @@ export async function POST(_request: Request, context: RouteContext) {
|
|||||||
},
|
},
|
||||||
factCard: factCardRecord,
|
factCard: factCardRecord,
|
||||||
});
|
});
|
||||||
const optimizedArticle = saveOptimizedArticle(undefined, jobId, result.article);
|
const optimizedArticle = await repository.saveOptimizedArticle(jobId, result.article);
|
||||||
const qaReport = saveQaReport(
|
const qaReport = await repository.saveQaReport(
|
||||||
undefined,
|
|
||||||
jobId,
|
jobId,
|
||||||
optimizedArticle.revision ?? 1,
|
optimizedArticle.revision ?? 1,
|
||||||
result.qaReport,
|
result.qaReport,
|
||||||
);
|
);
|
||||||
|
const exportStore = getExportStoreFromRuntime();
|
||||||
const exportPaths =
|
const exportPaths =
|
||||||
qaReport.overall_status === "fail"
|
qaReport.overall_status === "fail"
|
||||||
? {}
|
? {}
|
||||||
: await writeJobExports({
|
: await exportStore.writeJobExports({
|
||||||
jobId,
|
jobId,
|
||||||
article: optimizedArticle,
|
article: optimizedArticle,
|
||||||
qaReport,
|
qaReport,
|
||||||
});
|
});
|
||||||
updateArticleJob(undefined, jobId, {
|
await repository.updateArticleJob(jobId, {
|
||||||
status: qaReport.overall_status === "fail" ? "qa_failed" : "optimized",
|
status: qaReport.overall_status === "fail" ? "qa_failed" : "optimized",
|
||||||
export_paths: exportPaths,
|
export_paths: exportPaths,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import { NextResponse } from "next/server";
|
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 { 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) {
|
export async function POST(request: Request) {
|
||||||
|
const access = requireApiAccess(request);
|
||||||
|
if (!access.ok) {
|
||||||
|
return access.response;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await request.json();
|
const payload = (await request.json()) as RawArticleInput;
|
||||||
const normalized = normalizeInput(payload);
|
const normalized = normalizeInput(payload);
|
||||||
const job = createArticleJob(undefined, {
|
const repository = getRepositoryFromRuntime();
|
||||||
|
const job = await repository.createArticleJob({
|
||||||
source_title: normalized.articleInput.title,
|
source_title: normalized.articleInput.title,
|
||||||
source_body: normalized.articleInput.body,
|
source_body: normalized.articleInput.body,
|
||||||
image_inputs: normalized.articleInput.images,
|
image_inputs: normalized.articleInput.images,
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ h3 {
|
|||||||
color: #586174;
|
color: #586174;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.api-key-field {
|
||||||
|
min-width: min(16rem, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.workflow-grid {
|
.workflow-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|||||||
+42
-5
@@ -26,6 +26,24 @@ const initialInput: ArticleInputPayload = {
|
|||||||
user_instructions: "",
|
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() {
|
export default function Home() {
|
||||||
const [input, setInput] = useState(initialInput);
|
const [input, setInput] = useState(initialInput);
|
||||||
const [jobId, setJobId] = useState<string | null>(null);
|
const [jobId, setJobId] = useState<string | null>(null);
|
||||||
@@ -35,6 +53,7 @@ export default function Home() {
|
|||||||
const [qaReport, setQaReport] = useState<QaReport | null>(null);
|
const [qaReport, setQaReport] = useState<QaReport | null>(null);
|
||||||
const [busyAction, setBusyAction] = useState<string | null>(null);
|
const [busyAction, setBusyAction] = useState<string | null>(null);
|
||||||
const [message, setMessage] = useState<string>("");
|
const [message, setMessage] = useState<string>("");
|
||||||
|
const [apiAccessKey, setApiAccessKey] = useState("");
|
||||||
|
|
||||||
const exportBlocked = useMemo(
|
const exportBlocked = useMemo(
|
||||||
() => qaReport?.overall_status === "fail",
|
() => qaReport?.overall_status === "fail",
|
||||||
@@ -51,10 +70,10 @@ export default function Home() {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch("/api/jobs", {
|
const response = await fetch("/api/jobs", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: apiHeaders(apiAccessKey),
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
const body = await response.json();
|
const body = (await response.json()) as CreateJobResponse;
|
||||||
if (!response.ok) throw new Error(body.error ?? "分析失败");
|
if (!response.ok) throw new Error(body.error ?? "分析失败");
|
||||||
setJobId(body.job.id);
|
setJobId(body.job.id);
|
||||||
setFactCard(body.candidateFactCard);
|
setFactCard(body.candidateFactCard);
|
||||||
@@ -73,10 +92,10 @@ export default function Home() {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/jobs/${jobId}/confirm-fact-card`, {
|
const response = await fetch(`/api/jobs/${jobId}/confirm-fact-card`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: apiHeaders(apiAccessKey),
|
||||||
body: JSON.stringify(toConfirmedFactCard(factCard)),
|
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 ?? "确认失败");
|
if (!response.ok) throw new Error(body.error ?? "确认失败");
|
||||||
setFactCard(body.factCard);
|
setFactCard(body.factCard);
|
||||||
setMessage("事实卡已确认。");
|
setMessage("事实卡已确认。");
|
||||||
@@ -94,8 +113,9 @@ export default function Home() {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/jobs/${jobId}/optimize`, {
|
const response = await fetch(`/api/jobs/${jobId}/optimize`, {
|
||||||
method: "POST",
|
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 ?? "优化失败");
|
if (!response.ok) throw new Error(body.error ?? "优化失败");
|
||||||
setOptimizedArticle(body.optimizedArticle);
|
setOptimizedArticle(body.optimizedArticle);
|
||||||
setQaReport(body.qaReport);
|
setQaReport(body.qaReport);
|
||||||
@@ -118,6 +138,15 @@ export default function Home() {
|
|||||||
<h1>GEO 智能文章优化器</h1>
|
<h1>GEO 智能文章优化器</h1>
|
||||||
{message && <p>{message}</p>}
|
{message && <p>{message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
<label className="api-key-field">
|
||||||
|
<span>访问密钥</span>
|
||||||
|
<input
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(event) => setApiAccessKey(event.target.value)}
|
||||||
|
type="password"
|
||||||
|
value={apiAccessKey}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
disabled={!canOptimize || busyAction === "optimize"}
|
disabled={!canOptimize || busyAction === "optimize"}
|
||||||
onClick={optimize}
|
onClick={optimize}
|
||||||
@@ -149,3 +178,11 @@ export default function Home() {
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function apiHeaders(apiAccessKey: string) {
|
||||||
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||||
|
if (apiAccessKey) {
|
||||||
|
headers["x-api-key"] = apiAccessKey;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ConfirmedFactCard } from "../../domain/types";
|
||||||
import { optimizeArticle } from "../article-optimizer";
|
import { optimizeArticle } from "../article-optimizer";
|
||||||
import { extractCandidateFactCard } from "../fact-extractor";
|
import { extractCandidateFactCard } from "../fact-extractor";
|
||||||
import { normalizeInput } from "../input-normalizer";
|
import { normalizeInput } from "../input-normalizer";
|
||||||
@@ -7,7 +8,7 @@ import { inspectQuality } from "../quality-inspector";
|
|||||||
import { runOptimizationWorkflow } from "../orchestrator";
|
import { runOptimizationWorkflow } from "../orchestrator";
|
||||||
import { rewriteFailedSections } from "../targeted-rewriter";
|
import { rewriteFailedSections } from "../targeted-rewriter";
|
||||||
|
|
||||||
const confirmedFactCard = {
|
const confirmedFactCard: ConfirmedFactCard = {
|
||||||
company_full_name: "Example Technology Co., Ltd.",
|
company_full_name: "Example Technology Co., Ltd.",
|
||||||
company_short_names: ["Example Tech"],
|
company_short_names: ["Example Tech"],
|
||||||
brand_names: ["Example"],
|
brand_names: ["Example"],
|
||||||
@@ -21,7 +22,7 @@ const confirmedFactCard = {
|
|||||||
uncertain_items: [],
|
uncertain_items: [],
|
||||||
is_ready_for_optimization: true,
|
is_ready_for_optimization: true,
|
||||||
confirmed_by_user: true,
|
confirmed_by_user: true,
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
describe("workflow nodes", () => {
|
describe("workflow nodes", () => {
|
||||||
it("normalizes input whitespace and image lines", () => {
|
it("normalizes input whitespace and image lines", () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { expect, test } from "@playwright/test";
|
|||||||
test("中文界面可以生成优化文章和导出链接", async ({ page }) => {
|
test("中文界面可以生成优化文章和导出链接", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
|
await page.getByLabel("访问密钥").fill("local-dev-key");
|
||||||
await page.getByLabel("标题").fill("Example Technology Co., Ltd. GEO 指南");
|
await page.getByLabel("标题").fill("Example Technology Co., Ltd. GEO 指南");
|
||||||
await page
|
await page
|
||||||
.getByLabel("正文")
|
.getByLabel("正文")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
|
"types": ["node", "@cloudflare/workers-types"],
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
|
|||||||
@@ -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": {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user