Files
yingfungsolar/docs/superpowers/plans/2026-09-11-structure-ai-deploy.md
T
philip-cat fdee1aabd7 Add Zod validation layer and AI blog generation
Introduce a schema-first validation layer and an AI blog generation
feature, plus one-command Cloudflare provisioning.

- src/schemas/ holds Zod input schemas for post, content, case,
  settings, AI, and keywords; parseForm() in src/lib/form.ts validates
  FormData and returns per-field errors.
- Migrate all admin POST handlers to parseForm, showing field-level
  errors and only redirecting once validation passes.
- Add blog_keywords table and posts.focus_keyword (migration 0002);
  uniqueSlug() centralised in src/data/content.ts.
- Add src/lib/ai.ts (OpenAI-compatible chat completions + optional
  Tavily research) and /admin/ai for AI settings, keyword queue, and
  draft generation.
- Add scripts/setup.mjs (npm run setup) to provision D1/KV, set
  secrets, and optionally migrate, seed, and deploy.
- Document AI secrets, Workers Builds deploy flow, and new schemas
  across README and AGENTS docs.
2026-09-11 23:49:18 +08:00

48 KiB
Raw Blame History

結構優化 + AI Blog + 部署 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 為盈豐太陽能官網加入 Zod schema-first 驗證層、AI 生成 BlogDeepSeek + Tavily + 關鍵字佇列),同簡化 Cloudflare 部署。

Architecture: 沿用 Astro 7 SSR + D1 + Drizzle 單 Worker 架構。Phase 1 加 src/schemas/ + parseForm helper,將 admin 表單驗證集中。Phase 2 加 blog_keywords 表 + src/lib/ai.tsOpenAI 相容 chat completions + Tavily),新 /admin/ai 頁同步生成草稿。Phase 3 加 scripts/setup.mjs 一鍵 provision,並用 Cloudflare Workers Builds 做 Git push 自動 deploy。

Tech Stack: Astro 7 / React 19 / Chakra UI v3(前台)/ Drizzle ORM / Cloudflare D1 + KV / Zod v4 / marked / DeepSeekOpenAI 相容)/ Tavily。

驗證方式: 本 repo test / lint / typecheck scriptnpm run build 係唯一 build/type 檢查(見 AGENTS.md)。以下每個 Task 以 npm run build 收尾,需要時加 npm run dev 目測。本 repo 預設唔自動 commit;如要 commit,喺每個 Task 最後自行執行(訊息格式見範例)。

Spec docs/superpowers/specs/2026-09-11-structure-ai-deploy-design.md


檔案結構

新增:

src/schemas/post.ts        postInput
src/schemas/content.ts     contentItemInput
src/schemas/case.ts        caseInput
src/schemas/settings.ts    settingsInput(由 SETTINGS_GROUPS 動態砌)
src/schemas/ai.ts          aiSettingsInput + aiGeneratedPost
src/schemas/keyword.ts     keywordInput + keywordStatus
src/schemas/index.ts       barrel
src/lib/form.ts            parseForm(formData, schema)
src/lib/ai.ts              AI 生成流程(Tavily + DeepSeek + 解析)
src/pages/admin/ai.astro   AI 設定 + 關鍵字佇列 + 生成
scripts/setup.mjs          一鍵 Cloudflare provision

修改:

package.json                    + zod、+ "setup" script
src/db/schema.ts                posts.focusKeyword + blog_keywords 表
src/lib/env.ts                  + AI_API_KEY / TAVILY_API_KEY
src/data/content.ts             + getKeywords / uniqueSlug
src/layouts/AdminLayout.astro   + "AI" 導覽、+ .field-error CSS
src/pages/admin/post/[id].astro       用 parseForm + uniqueSlug
src/pages/admin/content/[kind].astro  用 parseForm
src/pages/admin/cases.astro           用 parseForm
src/pages/admin/settings.astro        用 parseForm
src/pages/admin/index.astro           加入口連結
migrations/seed.sql             加 AI 設定 + 示範關鍵字
.dev.vars.example               加 AI_API_KEY / TAVILY_API_KEY
README.md / AGENTS.md 各層       同步文件

Phase 1 — Zod schema-first 驗證層

Task 1: 加 zod 同建立 schemas

Files:

  • Modify: package.json

  • Create: src/schemas/post.tscontent.tscase.tssettings.tsai.tskeyword.tsindex.ts

  • Create: src/lib/form.ts

  • Step 1: 加 zod 依賴

npm install zod@^4.6.1

Expected: package.json dependencies 加入 "zod": "^4.6.1"(現時 zod 只係 astro 嘅 transitive dep)。

  • Step 2: 建 src/schemas/post.ts
import { z } from "zod";

export const postStatus = z.enum(["draft", "published"]);

export const postInput = z.object({
  title: z.string().trim().min(1, "標題唔可以留空。"),
  slug: z.string().trim().default(""),
  excerpt: z.string().trim().default(""),
  content: z.string().default(""),
  coverImage: z.string().trim().default(""),
  tags: z.string().trim().default(""),
  metaDescription: z.string().trim().default(""),
  status: postStatus.default("draft"),
});

export type PostInput = z.infer<typeof postInput>;
  • Step 3: 建 src/schemas/content.ts
import { z } from "zod";
import { CONTENT_KINDS } from "../db/schema";

export const contentItemInput = z.object({
  kind: z.enum(CONTENT_KINDS),
  title: z.string().trim().min(1, "名稱唔可以留空。"),
  description: z.string().default(""),
  extra: z.string().trim().default(""),
  status: z.enum(["draft", "published"]).default("published"),
});

export type ContentItemInput = z.infer<typeof contentItemInput>;
  • Step 4: 建 src/schemas/case.ts
import { z } from "zod";

export const caseInput = z.object({
  title: z.string().trim().min(1, "標題唔可以留空。"),
  location: z.string().trim().default(""),
  completedAt: z.string().trim().default(""),
  description: z.string().default(""),
  imageUrl: z.string().trim().default(""),
  status: z.enum(["draft", "published"]).default("published"),
});

export type CaseInput = z.infer<typeof caseInput>;
  • Step 5: 建 src/schemas/settings.ts
import { z } from "zod";
import { ALL_SETTING_KEYS } from "../data/settings-fields";

export const settingsInput = z.object(
  Object.fromEntries(
    ALL_SETTING_KEYS.map((key) => [key, z.string().trim().default("")]),
  ),
);

export type SettingsInput = z.infer<typeof settingsInput>;
  • Step 6: 建 src/schemas/ai.ts
import { z } from "zod";

export const aiSettingsInput = z.object({
  ai_enabled: z.enum(["1", "0"]).default("1"),
  ai_base_url: z.url("Base URL 要係有效網址。").default("https://api.deepinfra.com/v1/openai"),
  ai_chat_model: z.string().trim().min(1, "請填模型名稱。").default("deepseek-ai/DeepSeek-V3-0324"),
  ai_context_prompt: z.string().default(""),
  ai_business_context: z.string().default(""),
  ai_web_search_enabled: z.enum(["1", "0"]).default("0"),
  ai_web_search_max_results: z.coerce.number().int().min(1).max(10).default(5),
});

export type AiSettingsInput = z.infer<typeof aiSettingsInput>;

export const aiGeneratedPost = z.object({
  title: z.string().trim().min(1),
  content: z.string().trim().min(1),
  metaDescription: z.string().trim().default(""),
  excerpt: z.string().trim().default(""),
});

export type AiGeneratedPost = z.infer<typeof aiGeneratedPost>;
  • Step 7: 建 src/schemas/keyword.ts
import { z } from "zod";

export const keywordStatus = z.enum(["pending", "generated", "skipped"]);

export const keywordInput = z.object({
  keyword: z.string().trim().min(1, "關鍵字唔可以留空。"),
});

export type KeywordInput = z.infer<typeof keywordInput>;
  • Step 8: 建 src/schemas/index.ts
export * from "./post";
export * from "./content";
export * from "./case";
export * from "./settings";
export * from "./ai";
export * from "./keyword";
  • Step 9: 建 src/lib/form.ts
import type { ZodType } from "zod";

export type ParseResult<T> =
  | { ok: true; data: T }
  | { ok: false; errors: Record<string, string> };

/** 將 FormData 抽成物件再交畀 Zod 驗證;錯誤以 field -> 訊息 回傳。 */
export function parseForm<T>(form: FormData, schema: ZodType<T>): ParseResult<T> {
  const raw: Record<string, unknown> = {};
  for (const [key, value] of form.entries()) {
    if (typeof value === "string") raw[key] = value;
  }

  const result = schema.safeParse(raw);
  if (result.success) return { ok: true, data: result.data };

  const errors: Record<string, string> = {};
  for (const issue of result.error.issues) {
    const key = issue.path.join(".") || "_form";
    if (!errors[key]) errors[key] = issue.message;
  }
  return { ok: false, errors };
}
  • Step 10: 驗證
npm run build

Expected: build 成功(schemas 未有 callers,理應無 type error)。


Task 2: 抽出 uniqueSlug + 遷移文章編輯頁

Files:

  • Modify: src/data/content.ts(加 uniqueSlug

  • Modify: src/pages/admin/post/[id].astro

  • Step 1: 喺 src/data/content.ts 尾加 uniqueSlug

改 import 行(第 1 行)加入 ne

import { and, asc, desc, eq, ne } from "drizzle-orm";

喺檔案最後加:

/** slug 撞咗就加 -2、-3…;selfId 用喺更新自己時排除自己。 */
export async function uniqueSlug(db: Db, base: string, selfId?: string): Promise<string> {
  let candidate = base;
  for (let i = 2; i < 50; i++) {
    const rows = await db
      .select({ id: posts.id })
      .from(posts)
      .where(selfId ? and(eq(posts.slug, candidate), ne(posts.id, selfId)) : eq(posts.slug, candidate))
      .limit(1);
    if (rows.length === 0) return candidate;
    candidate = `${base}-${i}`;
  }
  return `${base}-${Date.now()}`;
}
  • Step 2: 改 src/pages/admin/post/[id].astro 的 imports

將第 27 行改成:

import { eq } from "drizzle-orm";
import AdminLayout from "../../../layouts/AdminLayout.astro";
import { getDb } from "../../../lib/db";
import { posts } from "../../../db/schema";
import { slugify, autoExcerpt } from "../../../lib/markdown";
import { getEnv } from "../../../lib/env";
import { uniqueSlug } from "../../../data/content";
import { parseForm } from "../../../lib/form";
import { postInput } from "../../../schemas";

(刪走原本嘅 and, ne 同本頁 local uniqueSlug 函式。)

  • Step 3: 刪走本頁 local uniqueSlug

移除原本第 2542 行(/** slug 撞咗就加 -2、-3… */ async function uniqueSlug(...))整個函式,改用 Task 2 Step 1 嘅共用版本(呼叫方式不變:uniqueSlug(db, base, selfId))。

  • Step 4: 改 error 變數同 POST 驗證邏輯

let error = ""; 改成:

let error = "";
let errors: Record<string, string> = {};

將 POST 內 if (action === "delete" ...) 之後嘅整段驗證(原本第 53–100 行,由 const title = ... 到 redirect 前)換成:

  const parsed = parseForm(form, postInput);
  if (!parsed.ok) {
    errors = parsed.errors;
    error = Object.values(parsed.errors)[0] ?? "輸入有誤。";
  } else {
    const data = parsed.data;
    const status = data.status;
    const slug = await uniqueSlug(
      db,
      data.slug ? slugify(data.slug) : slugify(data.title),
      isNew ? undefined : id,
    );
    const excerpt = data.excerpt || autoExcerpt(data.content);
    const metaDescription = data.metaDescription || excerpt.slice(0, 155);
    const coverImage = data.coverImage || null;
    const tags = data.tags || null;
    const now = new Date();

    if (isNew) {
      await db.insert(posts).values({
        id: crypto.randomUUID(),
        slug,
        title: data.title,
        excerpt,
        content: data.content,
        coverImage,
        tags,
        metaDescription,
        focusKeyword: null,
        status,
        publishedAt: status === "published" ? now : null,
        updatedAt: now,
      });
    } else {
      await db
        .update(posts)
        .set({
          slug,
          title: data.title,
          excerpt,
          content: data.content,
          coverImage,
          tags,
          metaDescription,
          status,
          publishedAt: existing!.publishedAt ?? (status === "published" ? now : null),
          updatedAt: now,
        })
        .where(eq(posts.id, id!));
    }

    return Astro.redirect("/admin");
  }

注意:focusKeyword: null 呢個欄位喺 Phase 2 Task 7 加;Phase 1 執行時先唔好加呢行,等 Phase 2 加咗 schema 先補返。Phase 1 版本省略 focusKeyword

  • Step 5: 表單加 per-field 錯誤顯示

將標題、slug、excerpt、coverImage、tags、metaDescription、content 每個 <label> 之後加(以標題為例):

<label for="title">標題</label>
{errors.title && <span class="field-error">{errors.title}</span>}
<input id="title" name="title" type="text" value={post.title} required placeholder="文章標題" />

其餘欄位用對應 keyerrors.slugerrors.excerpterrors.coverImageerrors.tagserrors.metaDescriptionerrors.content)。

  • Step 6: 驗證
npm run build

Expected: PASS。再 npm run db:migrate:local && npm run db:seed:local && npm run dev,開 /admin/post/post-001 改標題儲存、新增一篇、測標題留空睇 error。


Task 3: 遷移內容通用編輯器

Files:

  • Modify: src/pages/admin/content/[kind].astro

  • Step 1: 加 imports

喺第 5 行後加:

import { parseForm } from "../../../lib/form";
import { contentItemInput } from "../../../schemas";
  • Step 2: 加 errors 變數

const meta = META[kind]; 之後加:

let errors: Record<string, string> = {};
  • Step 3: 改 add / save 分支

if (action === "add") { ... } else if (action === "save") { ... } 兩段換成:

  if (action === "add" || action === "save") {
    form.set("kind", kind);
    const parsed = parseForm(form, contentItemInput);
    if (!parsed.ok) {
      errors = parsed.errors;
    } else if (action === "add") {
      const [row] = await db
        .select({ m: max(contentItems.sortOrder) })
        .from(contentItems)
        .where(eq(contentItems.kind, kind));
      await db.insert(contentItems).values({
        id: crypto.randomUUID(),
        kind,
        title: parsed.data.title,
        description: parsed.data.description,
        extra: parsed.data.extra || null,
        sortOrder: (row?.m ?? 0) + 1,
        status: parsed.data.status,
        updatedAt: new Date(),
      });
    } else {
      const id = str(form, "id");
      await db
        .update(contentItems)
        .set({
          title: parsed.data.title,
          description: parsed.data.description,
          extra: parsed.data.extra || null,
          status: parsed.data.status,
          updatedAt: new Date(),
        })
        .where(and(eq(contentItems.id, id), eq(contentItems.kind, kind)));
    }
  } else if (action === "delete") {

delete / up / down 分支維持原狀。)

  • Step 4: 新增與編輯表單加錯誤顯示

喺新增表單 {meta.titleLabel} label 後加 {errors.title && <span class="field-error">{errors.title}</span>};編輯表單同樣喺標題 label 後加。

  • Step 5: 驗證
npm run build

Expected: PASS。npm run dev/admin/content/service 新增、編輯、排序、刪除。


Task 4: 遷移完成案例頁

Files:

  • Modify: src/pages/admin/cases.astro

  • Step 1: 加 imports

喺第 6 行後加:

import { parseForm } from "../../lib/form";
import { caseInput } from "../../schemas";
  • Step 2: 加 errors 變數

const db = getDb(getEnv().DB); 之後加:

let errors: Record<string, string> = {};
  • Step 3: 改 add / save 分支

if (action === "add") { ... } else if (action === "save") { ... } 換成:

  if (action === "add" || action === "save") {
    const parsed = parseForm(form, caseInput);
    if (!parsed.ok) {
      errors = parsed.errors;
    } else if (action === "add") {
      const [row] = await db.select({ m: max(cases.sortOrder) }).from(cases);
      await db.insert(cases).values({
        id: crypto.randomUUID(),
        title: parsed.data.title,
        location: parsed.data.location || null,
        completedAt: parsed.data.completedAt || null,
        description: parsed.data.description || null,
        imageUrl: parsed.data.imageUrl || null,
        sortOrder: (row?.m ?? 0) + 1,
        status: parsed.data.status,
        updatedAt: new Date(),
      });
    } else {
      const id = str(form, "id");
      await db
        .update(cases)
        .set({
          title: parsed.data.title,
          location: parsed.data.location || null,
          completedAt: parsed.data.completedAt || null,
          description: parsed.data.description || null,
          imageUrl: parsed.data.imageUrl || null,
          status: parsed.data.status,
          updatedAt: new Date(),
        })
        .where(eq(cases.id, id));
    }
  } else if (action === "delete") {

delete / up / down 維持。)

  • Step 4: 新增與編輯表單加錯誤顯示

喺新增表單標題 label 後加 {errors.title && <span class="field-error">{errors.title}</span>};編輯表單同樣。

  • Step 5: 驗證
npm run build

Expected: PASS。npm run dev/admin/cases 新增、編輯、排序、刪除。


Task 5: 遷移網站設定頁

Files:

  • Modify: src/pages/admin/settings.astro

  • Step 1: 加 imports

喺第 7 行後加:

import { parseForm } from "../../lib/form";
import { settingsInput } from "../../schemas";
  • Step 2: 改 POST 邏輯

將第 1325 行(let saved = false; 至 POST 區塊完)換成:

let saved = false;
let error = "";

if (Astro.request.method === "POST") {
  const form = await Astro.request.formData();
  const parsed = parseForm(form, settingsInput);
  if (!parsed.ok) {
    error = Object.values(parsed.errors)[0] ?? "設定有誤。";
  } else {
    const now = new Date();
    for (const key of ALL_SETTING_KEYS) {
      const value = String((parsed.data as Record<string, unknown>)[key] ?? "");
      await db
        .insert(siteSettings)
        .values({ key, value, updatedAt: now })
        .onConflictDoUpdate({ target: siteSettings.key, set: { value, updatedAt: now } });
    }
    saved = true;
  }
}
  • Step 3: 顯示 error

{saved && ...} 之前加:

{error && <p class="field-error" style="margin-bottom:16px">{error}</p>}
  • Step 4: 驗證
npm run build

Expected: PASS。npm run dev/admin/settings 儲存後前台見到更新。


Task 6: AdminLayout 加 .field-error 樣式

Files:

  • Modify: src/layouts/AdminLayout.astro

  • Step 1: 加 CSS

.muted { ... } 之後(約第 109 行)加:

.field-error { color: #a32d2d; font-size: 12.5px; font-weight: 600; margin-left: 8px; }
  • Step 2: 驗證
npm run build

Expected: PASS。npm run dev → 提交一個空標題,見到紅色錯誤字。


Phase 2 — AI Blog + 關鍵字佇列

Task 7: DB schemaposts.focusKeyword + blog_keywords

Files:

  • Modify: src/db/schema.ts

  • Modify: migrations/seed.sql

  • Generate: migrations/0002_*.sql(由 drizzle-kit

  • Step 1: postsfocusKeyword

posts 定義 tags 之後加:

    focusKeyword: text("focus_keyword"),
  • Step 2: 加 blog_keywords

cases 定義之後、export type Post ... 之前加:

export const KEYWORD_STATUSES = ["pending", "generated", "skipped"] as const;
export type KeywordStatus = (typeof KEYWORD_STATUSES)[number];

/**
 * AI 生成用嘅關鍵字佇列(手動維護,冇 Google Ads discovery)。
 */
export const blogKeywords = sqliteTable(
  "blog_keywords",
  {
    id: text("id").primaryKey(),
    keyword: text("keyword").notNull(),
    status: text("status", { enum: KEYWORD_STATUSES }).notNull().default("pending"),
    createdAt: integer("created_at", { mode: "timestamp_ms" })
      .notNull()
      .$defaultFn(() => new Date()),
    usedAt: integer("used_at", { mode: "timestamp_ms" }),
    postId: text("post_id"),
  },
  (t) => [index("idx_keywords_status").on(t.status)],
);
  • Step 3: 加型別匯出

喺檔案最後加:

export type BlogKeyword = typeof blogKeywords.$inferSelect;
export type NewBlogKeyword = typeof blogKeywords.$inferInsert;
  • Step 4: 產生 migration
npm run db:generate

Expected: migrations/ 出現 0002_*.sql,內含 ALTER TABLE posts ADD COLUMN focus_keywordCREATE TABLE blog_keywords

  • Step 5: 套用到本機
npm run db:migrate:local

Expected: 成功,無 error。

  • Step 6: seed.sql 加 AI 設定

喺第一個 INSERT OR REPLACE INTO site_settings ...;(第 22 行 og_image)之後、content_items 之前,加:

INSERT OR REPLACE INTO site_settings (key, value, updated_at) VALUES
  ('ai_enabled', '1', strftime('%s','now')*1000),
  ('ai_base_url', 'https://api.deepinfra.com/v1/openai', strftime('%s','now')*1000),
  ('ai_chat_model', 'deepseek-ai/DeepSeek-V3-0324', strftime('%s','now')*1000),
  ('ai_context_prompt', '用香港繁體中文(廣東話書面語)書寫,語氣專業、親切、務實,避免誇大。', strftime('%s','now')*1000),
  ('ai_business_context', '盈豐太陽能工程有限公司專營香港村屋太陽能系統:現場評估、合法鋁合金支架、代辦中電/港燈上網電價(FiT)申請、專業安裝及驗收認證。着重透明報價、專人跟進、安全合規。', strftime('%s','now')*1000),
  ('ai_web_search_enabled', '0', strftime('%s','now')*1000),
  ('ai_web_search_max_results', '5', strftime('%s','now')*1000);
  • Step 7: seed.sql 加示範關鍵字

cases 嘅 insert(第 55 行)之後加:

INSERT OR REPLACE INTO blog_keywords (id, keyword, status, created_at, used_at, post_id) VALUES
  ('kw-001', '村屋太陽能回本期', 'pending', strftime('%s','now')*1000, NULL, NULL),
  ('kw-002', '村屋天台安裝太陽能注意事項', 'pending', strftime('%s','now')*1000, NULL, NULL),
  ('kw-003', '上網電價 FiT 申請流程', 'pending', strftime('%s','now')*1000, NULL, NULL);
  • Step 8: 補返 post 頁嘅 focusKeyword

喺 Task 2 Step 4 嘅 insert(posts).values({...}) 入面,tags, 之後加返:

        focusKeyword: null,
  • Step 9: 匯入種子並驗證
npm run db:seed:local && npm run build

Expected: seed 成功;build PASS。


Task 8: env 加 AI secrets

Files:

  • Modify: src/lib/env.ts

  • Modify: .dev.vars.example

  • Step 1: 改 AppEnv

export type AppEnv = {
  DB: D1Database;
  CACHE: KVNamespace;
  ADMIN_PASSWORD?: string;
  AI_API_KEY?: string;
  TAVILY_API_KEY?: string;
};
  • Step 2: 更新 .dev.vars.example
ADMIN_PASSWORD=改成你自己嘅密碼
AI_API_KEY=
TAVILY_API_KEY=
  • Step 3: 本機 .dev.vars 補 key

喺本機 .dev.vars(已 gitignore)加 AI_API_KEY=...(測試生成先需要)。

  • Step 4: 驗證
npm run build

Expected: PASS。


Task 9: src/lib/ai.ts

Files:

  • Create: src/lib/ai.ts

  • Step 1: 寫檔案

import { asc, eq } from "drizzle-orm";
import type { Db } from "../data/content";
import { getSettings, uniqueSlug } from "../data/content";
import { blogKeywords, posts } from "../db/schema";
import type { AppEnv } from "./env";
import { autoExcerpt, slugify } from "./markdown";
import { aiGeneratedPost } from "../schemas";

type AiSettings = {
  enabled: boolean;
  baseUrl: string;
  model: string;
  contextPrompt: string;
  businessContext: string;
  webSearchEnabled: boolean;
  webSearchMaxResults: number;
};

export async function getAiSettings(db: Db): Promise<AiSettings> {
  const s = await getSettings(db);
  return {
    enabled: s.ai_enabled !== "0",
    baseUrl: s.ai_base_url || "https://api.deepinfra.com/v1/openai",
    model: s.ai_chat_model || "deepseek-ai/DeepSeek-V3-0324",
    contextPrompt: s.ai_context_prompt ?? "",
    businessContext: s.ai_business_context ?? "",
    webSearchEnabled: s.ai_web_search_enabled === "1",
    webSearchMaxResults: Number(s.ai_web_search_max_results) || 5,
  };
}

type TavilyResult = { title?: string; url?: string; content?: string };

/** Tavily 搜尋,失敗回空字串(唔中斷生成)。 */
async function tavilyResearch(query: string, apiKey: string, maxResults: number): Promise<string> {
  try {
    const res = await fetch("https://api.tavily.com/search", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ api_key: apiKey, query, max_results: maxResults, search_depth: "basic" }),
    });
    if (!res.ok) return "";
    const data = (await res.json()) as { results?: TavilyResult[] };
    const results = data.results ?? [];
    return results
      .map((r, i) => `${i + 1}. ${r.title ?? ""}${r.url ?? ""}\n${r.content ?? ""}`)
      .join("\n\n");
  } catch {
    return "";
  }
}

function buildSystemPrompt(opts: {
  contextPrompt: string;
  businessContext: string;
  research: string;
  topic: string;
}): string {
  const parts: string[] = [];
  if (opts.contextPrompt) parts.push(opts.contextPrompt);
  if (opts.businessContext) parts.push(`## 公司背景資料\n${opts.businessContext}`);
  if (opts.research) parts.push(`## 網上研究資料(只作參考,唔好照抄)\n${opts.research}`);
  parts.push(`你係香港村屋太陽能公司「盈豐太陽能」嘅 SEO 內容寫手。請就以下主題寫一篇 800–1200 字嘅繁體中文(香港廣東話書面語)博客文章。

主題:${opts.topic}

要求:
- 標題要放焦點關鍵字,首 100 字內再出現一次,關鍵字密度約 1–2%。
- 用 ## / ### 分段,段落清晰易讀。
- 開頭先畀一段總結(summary-first),再展開。
- 內容要對香港村屋太陽能實用、準確,唔好作出未經證實嘅承諾或數字。
- 適合 SEO 同 AI 搜尋(GEO):用問答式小標題、必要時用列表。

輸出格式(必須嚴格跟隨,唔要加額外說明):
TITLE: <文章標題>
CONTENT:
<Markdown 正文,唔需要重複標題>
META_DESC: <150 字以內 SEO 描述>`);
  return parts.join("\n\n");
}

async function callChat(settings: AiSettings, apiKey: string, systemPrompt: string): Promise<string> {
  const res = await fetch(`${settings.baseUrl.replace(/\/$/, "")}/chat/completions`, {
    method: "POST",
    headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
    body: JSON.stringify({
      model: settings.model,
      messages: [{ role: "user", content: systemPrompt }],
      max_tokens: 2200,
      temperature: 0.7,
    }),
  });
  if (!res.ok) {
    const body = await res.text();
    throw new Error(`AI 供應商回應 ${res.status}${body.slice(0, 200)}`);
  }
  const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
  const text = data.choices?.[0]?.message?.content ?? "";
  if (!text) throw new Error("AI 冇回覆內容。");
  return text;
}

export type ParsedGenerated =
  | { ok: true; data: { title: string; content: string; metaDescription: string; excerpt: string } }
  | { ok: false; error: string };

export function parseGeneratedPost(text: string): ParsedGenerated {
  const titleMatch = text.match(/TITLE:\s*(.+)/i);
  const contentMatch = text.match(/CONTENT:\s*([\s\S]*?)(?:\nMETA_DESC:|$)/i);
  const metaMatch = text.match(/META_DESC:\s*([\s\S]+)/i);

  const content = (contentMatch?.[1] ?? "").trim();
  const metaDescription = (metaMatch?.[1] ?? "").trim().replace(/\s+/g, " ").slice(0, 160);
  const candidate = {
    title: (titleMatch?.[1] ?? "").trim(),
    content,
    metaDescription,
    excerpt: metaDescription || autoExcerpt(content),
  };

  const parsed = aiGeneratedPost.safeParse(candidate);
  if (!parsed.success) return { ok: false, error: "AI 輸出格式唔正確,請再試。" };
  return { ok: true, data: parsed.data };
}

export type GenerateResult = { ok: true; postId: string } | { ok: false; error: string };

export async function generateBlogPost(
  db: Db,
  env: AppEnv,
  input: { mode: "topic"; topic: string } | { mode: "next" },
): Promise<GenerateResult> {
  const settings = await getAiSettings(db);
  if (!settings.enabled) return { ok: false, error: "AI 生成未啟用。" };
  const apiKey = env.AI_API_KEY;
  if (!apiKey) return { ok: false, error: "未設定 AI_API_KEY。" };

  let topic: string;
  let keywordId: string | null = null;
  if (input.mode === "next") {
    const [next] = await db
      .select()
      .from(blogKeywords)
      .where(eq(blogKeywords.status, "pending"))
      .orderBy(asc(blogKeywords.createdAt))
      .limit(1);
    if (!next) return { ok: false, error: "關鍵字佇列冇 pending 項目。" };
    topic = next.keyword;
    keywordId = next.id;
  } else {
    topic = input.topic.trim();
    if (!topic) return { ok: false, error: "請輸入主題。" };
  }

  let research = "";
  if (settings.webSearchEnabled && env.TAVILY_API_KEY) {
    research = await tavilyResearch(topic, env.TAVILY_API_KEY, settings.webSearchMaxResults);
  }

  let generated;
  try {
    const text = await callChat(
      settings,
      apiKey,
      buildSystemPrompt({
        contextPrompt: settings.contextPrompt,
        businessContext: settings.businessContext,
        research,
        topic,
      }),
    );
    const parsed = parseGeneratedPost(text);
    if (!parsed.ok) return parsed;
    generated = parsed.data;
  } catch (err) {
    return { ok: false, error: err instanceof Error ? err.message : "生成失敗。" };
  }

  const now = new Date();
  const slug = await uniqueSlug(db, slugify(generated.title));
  const id = crypto.randomUUID();
  const excerpt = generated.excerpt || autoExcerpt(generated.content);

  await db.insert(posts).values({
    id,
    slug,
    title: generated.title,
    excerpt,
    content: generated.content,
    coverImage: null,
    tags: null,
    focusKeyword: topic,
    metaDescription: generated.metaDescription || excerpt.slice(0, 155),
    status: "draft",
    publishedAt: null,
    updatedAt: now,
  });

  if (keywordId) {
    await db
      .update(blogKeywords)
      .set({ status: "generated", usedAt: now, postId: id })
      .where(eq(blogKeywords.id, keywordId));
  }

  return { ok: true, postId: id };
}
  • Step 2: 驗證
npm run build

Expected: PASSai.ts 未接 UI,但要通過 type check)。


Task 10: data 層加關鍵字查詢

Files:

  • Modify: src/data/content.ts

  • Step 1: 加 getKeywords

改 schema import(第 312 行)加入 blogKeywordstype BlogKeyword

import {
  blogKeywords,
  cases,
  contentItems,
  posts,
  siteSettings,
  type BlogKeyword,
  type CaseStudy,
  type ContentItem,
  type ContentKind,
  type Post,
} from "../db/schema";

getPostBySlug 之後加:

export async function getKeywords(db: Db): Promise<BlogKeyword[]> {
  return db.select().from(blogKeywords).orderBy(asc(blogKeywords.createdAt));
}
  • Step 2: 驗證
npm run build

Expected: PASS。


Task 11: 新 /admin/ai

Files:

  • Create: src/pages/admin/ai.astro

  • Step 1: 寫檔案

---
import { asc, eq } from "drizzle-orm";
import AdminLayout from "../../layouts/AdminLayout.astro";
import { getDb } from "../../lib/db";
import { getEnv } from "../../lib/env";
import { getKeywords, getSettings } from "../../data/content";
import { blogKeywords, siteSettings } from "../../db/schema";
import { parseForm } from "../../lib/form";
import { aiSettingsInput, keywordInput } from "../../schemas";
import { generateBlogPost } from "../../lib/ai";

export const prerender = false;

const env = getEnv();
const db = getDb(env.DB);

let error = "";
let notice = "";

if (Astro.request.method === "POST") {
  const form = await Astro.request.formData();
  const action = String(form.get("action") ?? "");

  if (action === "save-settings") {
    form.set("ai_enabled", form.get("ai_enabled") ? "1" : "0");
    form.set("ai_web_search_enabled", form.get("ai_web_search_enabled") ? "1" : "0");
    const parsed = parseForm(form, aiSettingsInput);
    if (!parsed.ok) {
      error = Object.values(parsed.errors)[0] ?? "設定有誤。";
    } else {
      const now = new Date();
      for (const [key, value] of Object.entries(parsed.data)) {
        const v = String(value ?? "");
        await db
          .insert(siteSettings)
          .values({ key, value: v, updatedAt: now })
          .onConflictDoUpdate({ target: siteSettings.key, set: { value: v, updatedAt: now } });
      }
      notice = "已儲存 AI 設定。";
    }
  } else if (action === "add-keyword") {
    const parsed = parseForm(form, keywordInput);
    if (!parsed.ok) {
      error = parsed.errors.keyword ?? "請輸入關鍵字。";
    } else {
      await db.insert(blogKeywords).values({
        id: crypto.randomUUID(),
        keyword: parsed.data.keyword,
        status: "pending",
        createdAt: new Date(),
      });
      notice = "已加入關鍵字。";
    }
  } else if (action === "delete-keyword") {
    await db.delete(blogKeywords).where(eq(blogKeywords.id, String(form.get("id") ?? "")));
    notice = "已刪除。";
  } else if (action === "skip-keyword") {
    await db
      .update(blogKeywords)
      .set({ status: "skipped" })
      .where(eq(blogKeywords.id, String(form.get("id") ?? "")));
    notice = "已標記略過。";
  } else if (action === "generate-topic" || action === "generate-next") {
    const result = await generateBlogPost(
      db,
      env,
      action === "generate-next"
        ? { mode: "next" }
        : { mode: "topic", topic: String(form.get("topic") ?? "") },
    );
    if (result.ok) return Astro.redirect(`/admin/post/${result.postId}`);
    error = result.error;
  }
}

const settings = await getSettings(db);
const keywords = await getKeywords(db);
const secrets = { ai: Boolean(env.AI_API_KEY), tavily: Boolean(env.TAVILY_API_KEY) };
---

<AdminLayout title="AI 生成">
  <h1>AI 生成 Blog</h1>
  <p class="sub">設定 AI 供應商、維護關鍵字佇列,一按生成完整草稿(生成後會開啟文章編輯頁覆核)。</p>

  {error && <p class="field-error" style="display:block;margin-bottom:16px">{error}</p>}
  {
    notice && (
      <p class="badge published" style="display:inline-block;margin-bottom:16px;padding:6px 14px">
        ✓ {notice}
      </p>
    )
  }

  <div class="card">
    <h2>快速生成</h2>
    <form method="post" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end">
      <div style="flex:1;min-width:240px">
        <label for="topic">自由輸入主題</label>
        <input id="topic" name="topic" type="text" placeholder="例:村屋太陽能回本期點計?" />
      </div>
      <button type="submit" name="action" value="generate-topic">生成草稿</button>
      <button type="submit" name="action" value="generate-next" class="secondary">生成下一篇(佇列)</button>
    </form>
    <p class="muted" style="margin:10px 0 0">生成需時約 10–40 秒,請耐心等候,唔好重複提交。</p>
  </div>

  <div class="card">
    <h2>AI 設定</h2>
    <form method="post">
      <input type="hidden" name="action" value="save-settings" />
      <div class="grid2">
        <div>
          <label for="ai_enabled">AI 生成</label>
          <select id="ai_enabled" name="ai_enabled">
            <option value="1" selected={settings.ai_enabled !== "0"}>啟用</option>
            <option value="0" selected={settings.ai_enabled === "0"}>停用</option>
          </select>
        </div>
        <div>
          <label for="ai_chat_model">模型</label>
          <input id="ai_chat_model" name="ai_chat_model" type="text" value={settings.ai_chat_model ?? "deepseek-ai/DeepSeek-V3-0324"} />
        </div>
        <div>
          <label for="ai_base_url">OpenAI 相容 Base URL</label>
          <input id="ai_base_url" name="ai_base_url" type="text" value={settings.ai_base_url ?? "https://api.deepinfra.com/v1/openai"} />
        </div>
        <div>
          <label for="ai_web_search_enabled">上網研究(Tavily</label>
          <select id="ai_web_search_enabled" name="ai_web_search_enabled">
            <option value="1" selected={settings.ai_web_search_enabled === "1"}>啟用</option>
            <option value="0" selected={settings.ai_web_search_enabled !== "1"}>停用</option>
          </select>
        </div>
        <div>
          <label for="ai_web_search_max_results">Tavily 結果數(110</label>
          <input id="ai_web_search_max_results" name="ai_web_search_max_results" type="text" value={settings.ai_web_search_max_results ?? "5"} />
        </div>
        <div>
          <label>API Key 狀態</label>
          <p class="muted" style="margin:10px 0 0">
            AI_API_KEY{secrets.ai ? "已設定" : "未設定"} · TAVILY_API_KEY{secrets.tavily ? "已設定" : "未設定"}
          </p>
        </div>
        <div style="grid-column:1/-1">
          <label for="ai_context_prompt">寫作風格 / 語氣</label>
          <textarea id="ai_context_prompt" name="ai_context_prompt">{settings.ai_context_prompt ?? ""}</textarea>
        </div>
        <div style="grid-column:1/-1">
          <label for="ai_business_context">公司背景資料(grounding</label>
          <textarea id="ai_business_context" name="ai_business_context">{settings.ai_business_context ?? ""}</textarea>
        </div>
      </div>
      <div class="actions"><button type="submit">儲存 AI 設定</button></div>
    </form>
  </div>

  <div class="card">
    <h2>關鍵字佇列({keywords.length}</h2>
    <form method="post" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end">
      <input type="hidden" name="action" value="add-keyword" />
      <div style="flex:1;min-width:240px">
        <label for="keyword">新增關鍵字 / 主題</label>
        <input id="keyword" name="keyword" type="text" placeholder="例:太陽能板保養" />
      </div>
      <button type="submit" class="secondary">+ 加入</button>
    </form>

    {
      keywords.length === 0 ? (
        <p class="muted" style="margin-top:14px">暫時未有關鍵字。</p>
      ) : (
        <table style="margin-top:14px">
          <thead>
            <tr>
              <th>關鍵字</th>
              <th style="width:100px">狀態</th>
              <th style="width:120px"></th>
            </tr>
          </thead>
          <tbody>
            {keywords.map((k) => (
              <tr>
                <td>{k.keyword}</td>
                <td>
                  <span class={`badge ${k.status === "generated" ? "published" : "draft"}`}>
                    {k.status === "pending" ? "待生成" : k.status === "generated" ? "已生成" : "已略過"}
                  </span>
                </td>
                <td>
                  <form method="post" style="display:inline">
                    <input type="hidden" name="id" value={k.id} />
                    {k.status === "pending" && (
                      <button class="secondary mini" name="action" value="skip-keyword">略過</button>
                    )}
                    <button class="danger mini" name="action" value="delete-keyword" onclick="return confirm('確定刪除?')">
                      刪除
                    </button>
                  </form>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      )
    }
  </div>
</AdminLayout>
  • Step 2: 驗證
npm run build

Expected: PASS。

  • Step 3: 手動測試生成

本機 .dev.vars 設定 AI_API_KEY 後:

npm run dev

/admin/ai → 輸入主題 → 「生成草稿」→ 應 redirect 去新草稿編輯頁。若 key 未設定,應顯示明確錯誤訊息。


Task 12: Admin 導覽入口

Files:

  • Modify: src/layouts/AdminLayout.astro

  • Modify: src/pages/admin/index.astro

  • Step 1: 加導覽連結

links 陣列 { href: "/admin/settings", label: "設定" }, 之後加:

  { href: "/admin/ai", label: "AI 生成" },
  • Step 2: 喺 /admin 內容總覽加入口

首頁內容 卡片嘅 actions 入面,網站設定 連結之後加:

      <a class="btn secondary" href="/admin/ai">AI 生成</a>
  • Step 3: 驗證
npm run build

Expected: PASS。npm run dev → 後台導覽見到「AI 生成」,撳到去 /admin/ai


Phase 3 — 部署便利

Task 13: 一鍵本機 setup script

Files:

  • Create: scripts/setup.mjs

  • Modify: package.json

  • Step 1: 建 scripts/setup.mjs

#!/usr/bin/env node
import { execSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";

const D1_PLACEHOLDER = "PASTE_D1_DATABASE_ID_HERE";
const KV_PLACEHOLDER = "PASTE_KV_NAMESPACE_ID_HERE";
const WRANGLER = "wrangler.jsonc";
const DB_NAME = "yingfung-solar-db";

const rl = createInterface({ input, output });

function run(cmd) {
  return execSync(cmd, { encoding: "utf8", stdio: ["inherit", "pipe", "inherit"] });
}

function runSoft(cmd) {
  try {
    return { ok: true, out: run(cmd) };
  } catch (err) {
    return { ok: false, out: err.stdout ?? "" };
  }
}

async function main() {
  console.log("\n=== 盈豐太陽能 — Cloudflare 一鍵設定 ===\n");

  console.log("檢查 wrangler 登入狀態…");
  const who = runSoft("npx wrangler whoami");
  if (!who.ok) {
    console.error("未登入 Cloudflare。請先執行: npm run login");
    process.exit(1);
  }

  let config = readFileSync(WRANGLER, "utf8");

  if (config.includes(D1_PLACEHOLDER)) {
    console.log(`建立 D1 database「${DB_NAME}」…`);
    const res = runSoft(`npx wrangler d1 create ${DB_NAME}`);
    const out = res.out ?? "";
    const match = out.match(/database_id\s*=\s*"([0-9a-f-]+)"/i);
    if (!match) {
      console.error("成功建立但解析唔到 database_id。請手動將 id 填入 wrangler.jsonc。");
      console.error(out);
      process.exit(1);
    }
    config = config.replace(D1_PLACEHOLDER, match[1]);
    console.log(`  database_id = ${match[1]}`);
  } else {
    console.log("D1 id 已設定,略過。");
  }

  if (config.includes(KV_PLACEHOLDER)) {
    console.log("建立 KV namespace「CACHE」…");
    const res = runSoft("npx wrangler kv namespace create CACHE");
    const out = res.out ?? "";
    const match = out.match(/id\s*=\s*"([0-9a-f]+)"/i);
    if (!match) {
      console.error("成功建立但解析唔到 KV id。請手動將 id 填入 wrangler.jsonc。");
      console.error(out);
      process.exit(1);
    }
    config = config.replace(KV_PLACEHOLDER, match[1]);
    console.log(`  kv id = ${match[1]}`);
  } else {
    console.log("KV id 已設定,略過。");
  }

  writeFileSync(WRANGLER, config, "utf8");
  console.log("已更新 wrangler.jsonc。\n");

  const password = await rl.question("設定後台密碼 ADMIN_PASSWORD(留空略過):");
  if (password.trim()) {
    execSync("npx wrangler secret put ADMIN_PASSWORD", { input: password + "\n", stdio: ["pipe", "inherit", "inherit"] });
    console.log("ADMIN_PASSWORD 已設定。");
  } else {
    console.log("略過 ADMIN_PASSWORD。");
  }

  const migrate = await rl.question("而家套用雲端 migration + seed(y/N)");
  if (migrate.trim().toLowerCase() === "y") {
    execSync(`npx wrangler d1 migrations apply ${DB_NAME} --remote`, { stdio: "inherit" });
    execSync(`npx wrangler d1 execute ${DB_NAME} --remote --file=./migrations/seed.sql`, { stdio: "inherit" });
  }

  console.log("\n提示:上線前記得改 astro.config.mjs 嘅 site 做真域名。");
  const deploy = await rl.question("而家 build + deploy(y/N)");
  if (deploy.trim().toLowerCase() === "y") {
    execSync("npm run build", { stdio: "inherit" });
    execSync("npx wrangler deploy", { stdio: "inherit" });
  }

  console.log("\n完成。\n");
  rl.close();
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
  • Step 2: 加 package.json script

"types": "wrangler types" 之後加:

    "setup": "node scripts/setup.mjs"

(記得前一項加逗號。)

  • Step 3: 驗證
npm run build

Expected: PASS。node --check scripts/setup.mjs 無語法錯誤(node --check 只檢查語法)。


Task 14: README + Workers Builds / AGENTS 文件同步

Files:

  • Modify: README.md

  • Modify: AGENTS.mdroot

  • Modify: src/AGENTS.md

  • Modify: src/db/AGENTS.md

  • Modify: src/data/AGENTS.md

  • Modify: src/lib/AGENTS.md

  • Modify: migrations/AGENTS.md

  • Step 1: README 加 AI + setup + Workers Builds

更新「部署到 Cloudflare」一節,改成:

## 部署到 Cloudflare

### 首次設定(一鍵)

```bash
npm run login      # 瀏覽器授權
npm run setup      # 建立 D1 / KV、寫入 wrangler.jsonc、設 secret、套 migration + seed
```

`npm run setup` 會逐步問你:後台密碼、要唔要套 migration/seed、要唔要 build + deploy。

### 之後更新

改完 `astro.config.mjs``site` 做真域名後:

```bash
npm run deploy
```

### Git push 自動部署(Cloudflare Workers Builds

1. Cloudflare Dashboard → Workers & Pages → 連接 GitHub repo
2. Build command`npm run build`
3. Deploy command`npx wrangler deploy`
4. 喺 Dashboard 設 secrets`ADMIN_PASSWORD``AI_API_KEY``TAVILY_API_KEY`

**Migration 唔會喺 CI 自動執行**:改完 `src/db/schema.ts` 要手動跑
`npm run db:generate``npm run db:migrate`(雲端)→ `npm run db:seed`(如有新 seed)。

## AI 生成 Blog

後台 `/admin/ai`

- 設定 AI 供應商(OpenAI 相容 Base URL + 模型)、寫作風格、公司背景
- 維護關鍵字佇列
- 「生成草稿」= 自由輸入主題;「生成下一篇」= 攞佇列下一個 pending
- 生成結果一律存為**草稿**,去文章編輯頁覆核後先發布
- 需要 secret`AI_API_KEY`(必需)、`TAVILY_API_KEY`(如開上網研究)
  • Step 2: root AGENTS.md 更新

  • 「指令」一節加 npm run setup

  • 「關鍵慣例與陷阱」加:AI 生成用 AI_API_KEY / TAVILY_API_KEY secretZod schema 喺 src/schemas/Workers Builds 部署但 migration 手動。

  • Child DOX Index 加 src/schemas/?—— schemas 由 src/AGENTS.md 覆蓋,將 src/AGENTS.md 嘅 ownership 描述更新即可。

  • Step 3: src/AGENTS.md 更新

  • Ownership 加入 src/schemas/Zod 驗證層)同 src/lib/form.tssrc/lib/ai.ts

  • Routes & SSR 加入 /admin/ai

  • 說明 AI 生成係同步 await、Astro.locals.cfContext 為 ExecutionContext。

  • Step 4: src/db/AGENTS.md 更新

  • 表清單加 blog_keywordsKEYWORD_STATUSES

  • 說明 posts.focusKeyword

  • Step 5: src/data/AGENTS.md 更新

  • getKeywordsuniqueSlug

  • 型別來源改為「由 src/schemas/ re-export / z.infer」。

  • Step 6: src/lib/AGENTS.md 更新

  • ai.tsTavily + DeepSeek + 解析)、form.tsparseForm)。

  • env.tsAI_API_KEY / TAVILY_API_KEY

  • Step 7: migrations/AGENTS.md 更新

  • Seed 內容加 AI 設定 keys 同示範關鍵字。

  • 說明 migration 唔入 CI。

  • Step 8: 最終驗證

npm run build

Expected: PASS。人手對照 spec §2–§4 同實作。


Self-Review 摘要

  • Spec coverage:§2Zod)→ Task 16;§3AI)→ Task 712;§4(部署)→ Task 1314。§8 文件同步 → Task 14。全部有對應 task。
  • Placeholder:無 TBD / TODO;每個 code step 都有完整代碼。
  • Type 一致性uniqueSlug(db, base, selfId?)parseFormgetAiSettingsgenerateBlogPostgetKeywordsaiGeneratedPost 前後一致。
  • 已知取捨:同步生成(非背景);focusKeyword 喺 Task 7 先加(Task 2/4 執行時唔好加);settings.astroas Record<string, unknown> 索引係為咗動態 key。

實作偏差(review 後修正,實作為準)

  • Admin 驗證錯誤顯示content/[kind].astro / cases.astro 原本尾段無條件 redirect 會蓋過錯誤;改為只喺 errors 為空時 redirect,並用 failedId / failedAdd 分辨係新增定邊一行出錯(避免同一訊息顯示 N 次)。
  • settings.astro 只寫有提交嘅 keysubmitted set),防止未提交欄位被 "" 蓋走。
  • /admin/ai 用 PRGsave-settings / add-keyword / delete-keyword / skip-keyword 成功後 redirect("/admin/ai?ok=…"),避免 refresh 重複提交;驗證失敗照樣 render 錯誤。
  • 「生成中…」:生成表單用 inline onsubmitpointer-events:none + 改按鈕文字;唔用 disableddisabled 會令提交唔帶 action 值)。
  • ai.tsparseGeneratedPost 先正規化(去 code fence、全/半角冒號、粗體標籤);Tavily 用 Authorization: Bearer;兩個 fetch 加 AbortSignal.timeoutchat 90s、Tavily 15s);DB 寫入包 try/catchai_base_url 限制 http/https 並喺 UI 加憑證警告。
  • scripts/setup.mjsid 解析同時支援 JSON / TOML;登入檢查用 whoami --json;建立失敗會查 d1 list / kv namespace list 回收現有 ididempotent);密碼交畀 wrangler secret put(唔回顯);site 仍係 example.com 時跳過 deploy。
  • .gitignore:加入 .playwright-mcp/