Files
yingfungsolar/docs/superpowers/plans/2026-09-11-structure-ai-deploy.md
T
philip-cat 81fc515d6e AI Blog 改用 DeepSeek 官方並通用化提示詞
buildSystemPrompt 不再綁死太陽能行業,角色與公司資料改由
後台 ai_context_prompt / ai_business_context 提供;預設供應商
改為 https://api.deepseek.com 與 deepseek-flash。callChat 只在
baseUrl 含 deepseek.com 時加 thinking: { type: "disabled" },
並將 max_tokens 提升至 4000,避免思考模式令 temperature 失效
同正文被截斷。

同步更新 schemas、seed、/admin/ai 預設值同 placeholder,
以及 AGENTS.md、spec、plan 文件。
2026-09-12 00:31:26 +08:00

1559 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 結構優化 + 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.ts`OpenAI 相容 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 script`npm 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`
> **2026-09-12 更新:** AI Blog 提示詞已通用化、預設供應商轉 DeepSeek 官方(`https://api.deepseek.com` / `deepseek-flash`),詳見 `docs/superpowers/specs/2026-09-12-ai-blog-template-deepseek-design.md`。本計劃內舊嘅 `api.deepinfra.com` / `deepseek-ai/DeepSeek-V3-0324` 值已過時。
---
## 檔案結構
**新增:**
```
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.ts``content.ts``case.ts``settings.ts``ai.ts``keyword.ts``index.ts`
- Create: `src/lib/form.ts`
- [x] **Step 1: 加 zod 依賴**
```bash
npm install zod@^4.6.1
```
Expected: `package.json` dependencies 加入 `"zod": "^4.6.1"`(現時 zod 只係 astro 嘅 transitive dep)。
- [x] **Step 2: 建 `src/schemas/post.ts`**
```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>;
```
- [x] **Step 3: 建 `src/schemas/content.ts`**
```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>;
```
- [x] **Step 4: 建 `src/schemas/case.ts`**
```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>;
```
- [x] **Step 5: 建 `src/schemas/settings.ts`**
```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>;
```
- [x] **Step 6: 建 `src/schemas/ai.ts`**
```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>;
```
- [x] **Step 7: 建 `src/schemas/keyword.ts`**
```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>;
```
- [x] **Step 8: 建 `src/schemas/index.ts`**
```ts
export * from "./post";
export * from "./content";
export * from "./case";
export * from "./settings";
export * from "./ai";
export * from "./keyword";
```
- [x] **Step 9: 建 `src/lib/form.ts`**
```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 };
}
```
- [x] **Step 10: 驗證**
```bash
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`
- [x] **Step 1: 喺 `src/data/content.ts` 尾加 `uniqueSlug`**
改 import 行(第 1 行)加入 `ne`
```ts
import { and, asc, desc, eq, ne } from "drizzle-orm";
```
喺檔案最後加:
```ts
/** 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()}`;
}
```
- [x] **Step 2: 改 `src/pages/admin/post/[id].astro` 的 imports**
將第 27 行改成:
```ts
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` 函式。)
- [x] **Step 3: 刪走本頁 local `uniqueSlug`**
移除原本第 2542 行(`/** slug 撞咗就加 -2、-3… */ async function uniqueSlug(...)`)整個函式,改用 Task 2 Step 1 嘅共用版本(呼叫方式不變:`uniqueSlug(db, base, selfId)`)。
- [x] **Step 4: 改 error 變數同 POST 驗證邏輯**
`let error = "";` 改成:
```ts
let error = "";
let errors: Record<string, string> = {};
```
將 POST 內 `if (action === "delete" ...)` 之後嘅整段驗證(原本第 53–100 行,由 `const title = ...` 到 redirect 前)換成:
```ts
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`。
- [x] **Step 5: 表單加 per-field 錯誤顯示**
將標題、slug、excerpt、coverImage、tags、metaDescription、content 每個 `<label>` 之後加(以標題為例):
```astro
<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="文章標題" />
```
其餘欄位用對應 key`errors.slug``errors.excerpt``errors.coverImage``errors.tags``errors.metaDescription``errors.content`)。
- [x] **Step 6: 驗證**
```bash
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`
- [x] **Step 1: 加 imports**
喺第 5 行後加:
```ts
import { parseForm } from "../../../lib/form";
import { contentItemInput } from "../../../schemas";
```
- [x] **Step 2: 加 errors 變數**
`const meta = META[kind];` 之後加:
```ts
let errors: Record<string, string> = {};
```
- [x] **Step 3: 改 add / save 分支**
`if (action === "add") { ... } else if (action === "save") { ... }` 兩段換成:
```ts
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` 分支維持原狀。)
- [x] **Step 4: 新增與編輯表單加錯誤顯示**
喺新增表單 `{meta.titleLabel}` label 後加 `{errors.title && <span class="field-error">{errors.title}</span>}`;編輯表單同樣喺標題 label 後加。
- [x] **Step 5: 驗證**
```bash
npm run build
```
Expected: PASS。`npm run dev``/admin/content/service` 新增、編輯、排序、刪除。
---
## Task 4: 遷移完成案例頁
**Files:**
- Modify: `src/pages/admin/cases.astro`
- [x] **Step 1: 加 imports**
喺第 6 行後加:
```ts
import { parseForm } from "../../lib/form";
import { caseInput } from "../../schemas";
```
- [x] **Step 2: 加 errors 變數**
`const db = getDb(getEnv().DB);` 之後加:
```ts
let errors: Record<string, string> = {};
```
- [x] **Step 3: 改 add / save 分支**
`if (action === "add") { ... } else if (action === "save") { ... }` 換成:
```ts
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` 維持。)
- [x] **Step 4: 新增與編輯表單加錯誤顯示**
喺新增表單標題 label 後加 `{errors.title && <span class="field-error">{errors.title}</span>}`;編輯表單同樣。
- [x] **Step 5: 驗證**
```bash
npm run build
```
Expected: PASS。`npm run dev``/admin/cases` 新增、編輯、排序、刪除。
---
## Task 5: 遷移網站設定頁
**Files:**
- Modify: `src/pages/admin/settings.astro`
- [x] **Step 1: 加 imports**
喺第 7 行後加:
```ts
import { parseForm } from "../../lib/form";
import { settingsInput } from "../../schemas";
```
- [x] **Step 2: 改 POST 邏輯**
將第 1325 行(`let saved = false;` 至 POST 區塊完)換成:
```ts
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;
}
}
```
- [x] **Step 3: 顯示 error**
`{saved && ...}` 之前加:
```astro
{error && <p class="field-error" style="margin-bottom:16px">{error}</p>}
```
- [x] **Step 4: 驗證**
```bash
npm run build
```
Expected: PASS。`npm run dev``/admin/settings` 儲存後前台見到更新。
---
## Task 6: AdminLayout 加 `.field-error` 樣式
**Files:**
- Modify: `src/layouts/AdminLayout.astro`
- [x] **Step 1: 加 CSS**
`.muted { ... }` 之後(約第 109 行)加:
```css
.field-error { color: #a32d2d; font-size: 12.5px; font-weight: 600; margin-left: 8px; }
```
- [x] **Step 2: 驗證**
```bash
npm run build
```
Expected: PASS。`npm run dev` → 提交一個空標題,見到紅色錯誤字。
---
# Phase 2 — AI Blog + 關鍵字佇列
## Task 7: DB schema`posts.focusKeyword` + `blog_keywords`
**Files:**
- Modify: `src/db/schema.ts`
- Modify: `migrations/seed.sql`
- Generate: `migrations/0002_*.sql`(由 drizzle-kit
- [x] **Step 1: `posts` 加 `focusKeyword`**
`posts` 定義 `tags` 之後加:
```ts
focusKeyword: text("focus_keyword"),
```
- [x] **Step 2: 加 `blog_keywords` 表**
`cases` 定義之後、`export type Post ...` 之前加:
```ts
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)],
);
```
- [x] **Step 3: 加型別匯出**
喺檔案最後加:
```ts
export type BlogKeyword = typeof blogKeywords.$inferSelect;
export type NewBlogKeyword = typeof blogKeywords.$inferInsert;
```
- [x] **Step 4: 產生 migration**
```bash
npm run db:generate
```
Expected: `migrations/` 出現 `0002_*.sql`,內含 `ALTER TABLE posts ADD COLUMN focus_keyword``CREATE TABLE blog_keywords`
- [x] **Step 5: 套用到本機**
```bash
npm run db:migrate:local
```
Expected: 成功,無 error。
- [x] **Step 6: `seed.sql` 加 AI 設定**
喺第一個 `INSERT OR REPLACE INTO site_settings ...;`(第 22 行 `og_image`)之後、`content_items` 之前,加:
```sql
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);
```
- [x] **Step 7: `seed.sql` 加示範關鍵字**
`cases` 嘅 insert(第 55 行)之後加:
```sql
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);
```
- [x] **Step 8: 補返 post 頁嘅 `focusKeyword`**
喺 Task 2 Step 4 嘅 `insert(posts).values({...})` 入面,`tags,` 之後加返:
```ts
focusKeyword: null,
```
- [x] **Step 9: 匯入種子並驗證**
```bash
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`
- [x] **Step 1: 改 `AppEnv`**
```ts
export type AppEnv = {
DB: D1Database;
CACHE: KVNamespace;
ADMIN_PASSWORD?: string;
AI_API_KEY?: string;
TAVILY_API_KEY?: string;
};
```
- [x] **Step 2: 更新 `.dev.vars.example`**
```
ADMIN_PASSWORD=改成你自己嘅密碼
AI_API_KEY=
TAVILY_API_KEY=
```
- [x] **Step 3: 本機 `.dev.vars` 補 key**
喺本機 `.dev.vars`(已 gitignore)加 `AI_API_KEY=...`(測試生成先需要)。
- [x] **Step 4: 驗證**
```bash
npm run build
```
Expected: PASS。
---
## Task 9: `src/lib/ai.ts`
**Files:**
- Create: `src/lib/ai.ts`
- [x] **Step 1: 寫檔案**
```ts
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 };
}
```
- [x] **Step 2: 驗證**
```bash
npm run build
```
Expected: PASS`ai.ts` 未接 UI,但要通過 type check)。
---
## Task 10: data 層加關鍵字查詢
**Files:**
- Modify: `src/data/content.ts`
- [x] **Step 1: 加 `getKeywords`**
改 schema import(第 312 行)加入 `blogKeywords``type BlogKeyword`
```ts
import {
blogKeywords,
cases,
contentItems,
posts,
siteSettings,
type BlogKeyword,
type CaseStudy,
type ContentItem,
type ContentKind,
type Post,
} from "../db/schema";
```
`getPostBySlug` 之後加:
```ts
export async function getKeywords(db: Db): Promise<BlogKeyword[]> {
return db.select().from(blogKeywords).orderBy(asc(blogKeywords.createdAt));
}
```
- [x] **Step 2: 驗證**
```bash
npm run build
```
Expected: PASS。
---
## Task 11: 新 `/admin/ai` 頁
**Files:**
- Create: `src/pages/admin/ai.astro`
- [x] **Step 1: 寫檔案**
```astro
---
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>
```
- [x] **Step 2: 驗證**
```bash
npm run build
```
Expected: PASS。
- [x] **Step 3: 手動測試生成**
本機 `.dev.vars` 設定 `AI_API_KEY` 後:
```bash
npm run dev
```
`/admin/ai` → 輸入主題 → 「生成草稿」→ 應 redirect 去新草稿編輯頁。若 key 未設定,應顯示明確錯誤訊息。
---
## Task 12: Admin 導覽入口
**Files:**
- Modify: `src/layouts/AdminLayout.astro`
- Modify: `src/pages/admin/index.astro`
- [x] **Step 1: 加導覽連結**
`links` 陣列 `{ href: "/admin/settings", label: "設定" },` 之後加:
```ts
{ href: "/admin/ai", label: "AI 生成" },
```
- [x] **Step 2: 喺 `/admin` 內容總覽加入口**
`首頁內容` 卡片嘅 actions 入面,`網站設定` 連結之後加:
```astro
<a class="btn secondary" href="/admin/ai">AI 生成</a>
```
- [x] **Step 3: 驗證**
```bash
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`
- [x] **Step 1: 建 `scripts/setup.mjs`**
```js
#!/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);
});
```
- [x] **Step 2: 加 `package.json` script**
`"types": "wrangler types"` 之後加:
```json
"setup": "node scripts/setup.mjs"
```
(記得前一項加逗號。)
- [x] **Step 3: 驗證**
```bash
npm run build
```
Expected: PASS。`node --check scripts/setup.mjs` 無語法錯誤(`node --check` 只檢查語法)。
---
## Task 14: README + Workers Builds / AGENTS 文件同步
**Files:**
- Modify: `README.md`
- Modify: `AGENTS.md`root
- Modify: `src/AGENTS.md`
- Modify: `src/db/AGENTS.md`
- Modify: `src/data/AGENTS.md`
- Modify: `src/lib/AGENTS.md`
- Modify: `migrations/AGENTS.md`
- [x] **Step 1: README 加 AI + setup + Workers Builds**
更新「部署到 Cloudflare」一節,改成:
````markdown
## 部署到 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`(如開上網研究)
````
- [x] **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 描述更新即可。
- [x] **Step 3: `src/AGENTS.md` 更新**
- Ownership 加入 `src/schemas/`Zod 驗證層)同 `src/lib/form.ts`、`src/lib/ai.ts`。
- Routes & SSR 加入 `/admin/ai`。
- 說明 AI 生成係同步 await、`Astro.locals.cfContext` 為 ExecutionContext。
- [x] **Step 4: `src/db/AGENTS.md` 更新**
- 表清單加 `blog_keywords``KEYWORD_STATUSES`。
- 說明 `posts.focusKeyword`。
- [x] **Step 5: `src/data/AGENTS.md` 更新**
- 加 `getKeywords`、`uniqueSlug`。
- 型別來源改為「由 `src/schemas/` re-export / `z.infer`」。
- [x] **Step 6: `src/lib/AGENTS.md` 更新**
- 加 `ai.ts`Tavily + DeepSeek + 解析)、`form.ts`parseForm)。
- `env.ts` 加 `AI_API_KEY` / `TAVILY_API_KEY`。
- [x] **Step 7: `migrations/AGENTS.md` 更新**
- Seed 內容加 AI 設定 keys 同示範關鍵字。
- 說明 migration 唔入 CI。
- [x] **Step 8: 最終驗證**
```bash
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?)`、`parseForm`、`getAiSettings`、`generateBlogPost`、`getKeywords`、`aiGeneratedPost` 前後一致。
- **已知取捨**:同步生成(非背景);`focusKeyword` 喺 Task 7 先加(Task 2/4 執行時唔好加);`settings.astro` 用 `as Record<string, unknown>` 索引係為咗動態 key。
---
## 實作偏差(review 後修正,實作為準)
- **Admin 驗證錯誤顯示**`content/[kind].astro` / `cases.astro` 原本尾段無條件 `redirect` 會蓋過錯誤;改為只喺 `errors` 為空時 redirect,並用 `failedId` / `failedAdd` 分辨係新增定邊一行出錯(避免同一訊息顯示 N 次)。
- **`settings.astro` 只寫有提交嘅 key**`submitted` set),防止未提交欄位被 `""` 蓋走。
- **`/admin/ai` 用 PRG**`save-settings` / `add-keyword` / `delete-keyword` / `skip-keyword` 成功後 `redirect("/admin/ai?ok=…")`,避免 refresh 重複提交;驗證失敗照樣 render 錯誤。
- **「生成中…」**:生成表單用 inline `onsubmit` 設 `pointer-events:none` + 改按鈕文字;**唔用 `disabled`**disabled 會令提交唔帶 `action` 值)。
- **`ai.ts`**`parseGeneratedPost` 先正規化(去 code fence、全/半角冒號、粗體標籤);Tavily 用 `Authorization: Bearer`;兩個 fetch 加 `AbortSignal.timeout`chat 90s、Tavily 15s);DB 寫入包 try/catch`ai_base_url` 限制 http/https 並喺 UI 加憑證警告。
- **`scripts/setup.mjs`**id 解析同時支援 JSON / TOML;登入檢查用 `whoami --json`;建立失敗會查 `d1 list` / `kv namespace list` 回收現有 ididempotent);密碼交畀 `wrangler secret put`(唔回顯);`site` 仍係 `example.com` 時跳過 deploy。
- **`.gitignore`**:加入 `.playwright-mcp/`。