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 文件。
This commit is contained in:
Vendored
+159
@@ -0,0 +1,159 @@
|
||||
declare module 'astro:content' {
|
||||
export interface RenderResult {
|
||||
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
|
||||
headings: import('astro').MarkdownHeading[];
|
||||
remarkPluginFrontmatter: Record<string, any>;
|
||||
}
|
||||
interface Render {
|
||||
'.md': Promise<RenderResult>;
|
||||
}
|
||||
|
||||
export interface RenderedContent {
|
||||
html: string;
|
||||
metadata?: {
|
||||
imagePaths: Array<string>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
|
||||
|
||||
export type CollectionKey = keyof DataEntryMap;
|
||||
export type CollectionEntry<C extends CollectionKey> = Flatten<DataEntryMap[C]>;
|
||||
|
||||
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
|
||||
|
||||
export type ReferenceDataEntry<
|
||||
C extends CollectionKey,
|
||||
E extends keyof DataEntryMap[C] = string,
|
||||
> = {
|
||||
collection: C;
|
||||
id: E;
|
||||
};
|
||||
|
||||
export type ReferenceLiveEntry<C extends keyof LiveContentConfig['collections']> = {
|
||||
collection: C;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export function getCollection<C extends keyof DataEntryMap, E extends CollectionEntry<C>>(
|
||||
collection: C,
|
||||
filter?: (entry: CollectionEntry<C>) => entry is E,
|
||||
): Promise<E[]>;
|
||||
export function getCollection<C extends keyof DataEntryMap>(
|
||||
collection: C,
|
||||
filter?: (entry: CollectionEntry<C>) => unknown,
|
||||
): Promise<CollectionEntry<C>[]>;
|
||||
|
||||
export function getLiveCollection<C extends keyof LiveContentConfig['collections']>(
|
||||
collection: C,
|
||||
filter?: LiveLoaderCollectionFilterType<C>,
|
||||
): Promise<
|
||||
import('astro').LiveDataCollectionResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>
|
||||
>;
|
||||
|
||||
export function getEntry<
|
||||
C extends keyof DataEntryMap,
|
||||
E extends keyof DataEntryMap[C] | (string & {}),
|
||||
>(
|
||||
entry: ReferenceDataEntry<C, E>,
|
||||
): E extends keyof DataEntryMap[C]
|
||||
? Promise<DataEntryMap[C][E]>
|
||||
: Promise<CollectionEntry<C> | undefined>;
|
||||
export function getEntry<
|
||||
C extends keyof DataEntryMap,
|
||||
E extends keyof DataEntryMap[C] | (string & {}),
|
||||
>(
|
||||
collection: C,
|
||||
id: E,
|
||||
): E extends keyof DataEntryMap[C]
|
||||
? string extends keyof DataEntryMap[C]
|
||||
? Promise<DataEntryMap[C][E]> | undefined
|
||||
: Promise<DataEntryMap[C][E]>
|
||||
: Promise<CollectionEntry<C> | undefined>;
|
||||
export function getLiveEntry<C extends keyof LiveContentConfig['collections']>(
|
||||
collection: C,
|
||||
filter: string | LiveLoaderEntryFilterType<C>,
|
||||
): Promise<import('astro').LiveDataEntryResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>>;
|
||||
|
||||
/** Resolve an array of entry references from the same collection */
|
||||
export function getEntries<C extends keyof DataEntryMap>(
|
||||
entries: ReferenceDataEntry<C, keyof DataEntryMap[C]>[],
|
||||
): Promise<CollectionEntry<C>[]>;
|
||||
|
||||
export function render<C extends keyof DataEntryMap>(
|
||||
entry: DataEntryMap[C][string],
|
||||
): Promise<RenderResult>;
|
||||
|
||||
export function render<C extends keyof LiveContentConfig['collections']>(
|
||||
entry: import('astro').LiveDataEntry<LiveLoaderDataType<C>>,
|
||||
): Promise<RenderResult>;
|
||||
|
||||
export function reference<
|
||||
C extends
|
||||
| keyof DataEntryMap
|
||||
// Allow generic `string` to avoid excessive type errors in the config
|
||||
// if `dev` is not running to update as you edit.
|
||||
// Invalid collection names will be caught at build time.
|
||||
| (string & {}),
|
||||
>(
|
||||
collection: C,
|
||||
): import('astro/zod').ZodPipe<
|
||||
import('astro/zod').ZodString,
|
||||
import('astro/zod').ZodTransform<
|
||||
C extends keyof DataEntryMap
|
||||
? {
|
||||
collection: C;
|
||||
id: string;
|
||||
}
|
||||
: never,
|
||||
string
|
||||
>
|
||||
>;
|
||||
|
||||
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
|
||||
type InferEntrySchema<C extends keyof DataEntryMap> = import('astro/zod').infer<
|
||||
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
|
||||
>;
|
||||
type ExtractLoaderConfig<T> = T extends { loader: infer L } ? L : never;
|
||||
type InferLoaderSchema<
|
||||
C extends keyof DataEntryMap,
|
||||
L = ExtractLoaderConfig<ContentConfig['collections'][C]>,
|
||||
> = L extends { schema: import('astro/zod').ZodSchema }
|
||||
? import('astro/zod').infer<L['schema']>
|
||||
: any;
|
||||
|
||||
type DataEntryMap = {
|
||||
|
||||
};
|
||||
|
||||
type ExtractLoaderTypes<T> = T extends import('astro/loaders').LiveLoader<
|
||||
infer TData,
|
||||
infer TEntryFilter,
|
||||
infer TCollectionFilter,
|
||||
infer TError
|
||||
>
|
||||
? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError }
|
||||
: { data: never; entryFilter: never; collectionFilter: never; error: never };
|
||||
type ExtractEntryFilterType<T> = ExtractLoaderTypes<T>['entryFilter'];
|
||||
type ExtractCollectionFilterType<T> = ExtractLoaderTypes<T>['collectionFilter'];
|
||||
type ExtractErrorType<T> = ExtractLoaderTypes<T>['error'];
|
||||
type ExtractDataType<T> = ExtractLoaderTypes<T>['data'];
|
||||
|
||||
type LiveLoaderDataType<C extends keyof LiveContentConfig['collections']> =
|
||||
LiveContentConfig['collections'][C]['schema'] extends undefined
|
||||
? ExtractDataType<LiveContentConfig['collections'][C]['loader']>
|
||||
: import('astro/zod').infer<
|
||||
Exclude<LiveContentConfig['collections'][C]['schema'], undefined>
|
||||
>;
|
||||
type LiveLoaderEntryFilterType<C extends keyof LiveContentConfig['collections']> =
|
||||
ExtractEntryFilterType<LiveContentConfig['collections'][C]['loader']>;
|
||||
type LiveLoaderCollectionFilterType<C extends keyof LiveContentConfig['collections']> =
|
||||
ExtractCollectionFilterType<LiveContentConfig['collections'][C]['loader']>;
|
||||
type LiveLoaderErrorType<C extends keyof LiveContentConfig['collections']> = ExtractErrorType<
|
||||
LiveContentConfig['collections'][C]['loader']
|
||||
>;
|
||||
|
||||
export type ContentConfig = never;
|
||||
export type LiveContentConfig = never;
|
||||
}
|
||||
Vendored
+2
-1
@@ -1,2 +1,3 @@
|
||||
/// <reference types="astro/client" />
|
||||
/// <reference path="integrations/_astrojs_cloudflare/cloudflare.d.ts" />
|
||||
/// <reference path="integrations/_astrojs_cloudflare/cloudflare.d.ts" />
|
||||
/// <reference path="content.d.ts" />
|
||||
@@ -19,7 +19,7 @@ Astro 7 + React 19 + Chakra UI v3,部署在 Cloudflare Workers(單一 Worker
|
||||
- **資料層**:`getDb(getEnv().DB)` → Drizzle;查詢集中在 `src/data/content.ts`,schema 在 `src/db/schema.ts`。改 schema 後跑 `npm run db:generate` 產生 migration。
|
||||
- **Seed 與 migration 是分開的**:`db:migrate:local` / `db:migrate` 只套 migrations,`migrations/seed.sql` 要用 `db:seed:local` / `db:seed` 另外執行;seed 可重複跑。
|
||||
- **表單驗證集中在 `src/schemas/`**(Zod)+ `src/lib/form.ts` 的 `parseForm(form, schema)`:admin 頁面唔好再手寫逐欄驗證。實體輸入型別用 `z.infer` 由 schemas 匯出。
|
||||
- **AI Blog**:`src/lib/ai.ts`(OpenAI 相容 chat completions + Tavily)+ `/admin/ai`;同步生成、一律存草稿並標記關鍵字 `generated`。需要 secrets `AI_API_KEY`(必需)/`TAVILY_API_KEY`(可選);未設時回明確錯誤,唔會 throw。
|
||||
- **AI Blog**:`src/lib/ai.ts`(OpenAI 相容 chat completions + Tavily)+ `/admin/ai`;**預設供應商 DeepSeek 官方(`https://api.deepseek.com`、`deepseek-flash`)**,提示詞通用唔綁行業(角色/公司資料由後台 `ai_context_prompt`/`ai_business_context` 提供)。同步生成、一律存草稿並標記關鍵字 `generated`。需要 secrets `AI_API_KEY`(必需)/`TAVILY_API_KEY`(可選);未設時回明確錯誤,唔會 throw。已部署 DB 轉供應商要喺 `/admin/ai` 改,唔好重跑 `db:seed`(會覆蓋後台設定)。
|
||||
- **部署用 Cloudflare Workers Builds**(Git push 自動):build `npm run build`、deploy `npx wrangler deploy`。**Migration 唔會喺 CI 執行**,改 schema 要手動 `npm run db:migrate`(雲端)。
|
||||
- **`wrangler.jsonc` 有佔位 id**:`database_id` / KV `id` 要先用 `db:create` / `kv:create` 產生再貼上,未貼前無法部署。
|
||||
- **上線前要改 `astro.config.mjs` 的 `site`**:sitemap / canonical / OG 全部靠它(現為 `https://example.com`)。
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
**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` 值已過時。
|
||||
|
||||
---
|
||||
|
||||
## 檔案結構
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
2. 想有 AI 生成 Blog 文章功能(參考 webtemplate,但簡化、唔要 Google keyword search)
|
||||
3. 部署到 Cloudflare 嘅流程手續多,想更方便
|
||||
- **本 spec 取代**先前 spec 嘅部署一節;其餘架構決策不變。
|
||||
- **2026-09-12 更新**:AI Blog 提示詞已通用化、預設供應商轉 DeepSeek 官方,詳見 `2026-09-12-ai-blog-template-deepseek-design.md`;下表 `ai_base_url` / `ai_chat_model` 已同步為新預設。
|
||||
|
||||
---
|
||||
|
||||
@@ -110,8 +111,8 @@ src/lib/form.ts parseForm(formData, schema)
|
||||
| Key | 預設 | 說明 |
|
||||
|---|---|---|
|
||||
| `ai_enabled` | `1` | 總開關 |
|
||||
| `ai_base_url` | `https://api.deepinfra.com/v1/openai` | OpenAI 相容端點 |
|
||||
| `ai_chat_model` | `deepseek-ai/DeepSeek-V3-0324` | 模型 |
|
||||
| `ai_base_url` | `https://api.deepseek.com` | OpenAI 相容端點(DeepSeek 官方) |
|
||||
| `ai_chat_model` | `deepseek-flash` | 模型 |
|
||||
| `ai_context_prompt` | `""` | 寫作風格 / 語氣 |
|
||||
| `ai_business_context` | seed 由公司資料砌 | 公司背景(grounding) |
|
||||
| `ai_web_search_enabled` | `0` | Tavily 研究開關 |
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# AI Blog 通用化(Template-ready)+ 轉用 DeepSeek 官方 (Design Spec)
|
||||
|
||||
- **日期**:2026-09-12
|
||||
- **狀態**:已與客戶確認方向
|
||||
- **背景**:本網站打算做成可重用 template,套用到唔同公司。但現時 AI Blog 嘅 system prompt 硬編碼咗「香港村屋太陽能公司『盈豐太陽能』」,只啱太陽能公司用。同時客戶想由 DeepInfra 轉用 DeepSeek 官方供應商。
|
||||
- **本 spec 只涵蓋 AI Blog 生成**;其他太陽能硬編碼內容(`config.ts`、其他 seed、前台文案)唔喺今次範圍。
|
||||
- **相關**:`2026-09-11-structure-ai-deploy-design.md`(AI Blog 原始設計)依然有效,本 spec 係其「通用化 + 供應商」修訂。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目標與非目標
|
||||
|
||||
### 目標
|
||||
|
||||
1. **提示詞通用化**:`src/lib/ai.ts` 嘅 `buildSystemPrompt` 唔再綁死任何行業/公司;角色同公司資料一律由後台設定提供,令同一套 code 可套用到任何行業。
|
||||
2. **轉用 DeepSeek 官方**:預設供應商改成 `https://api.deepseek.com`+`deepseek-flash`,並處理 DeepSeek「思考模式預設開啟」對生成嘅影響。
|
||||
3. **後台易輸入**:`/admin/ai` 為公司背景同寫作風格加格式指引,令新公司開箱即用。
|
||||
|
||||
### 非目標 (Out of scope)
|
||||
|
||||
- 輸出格式維持 `TITLE / CONTENT / META_DESC`;**唔加** SECTION、**唔加** AI 封面圖(同原 spec 一致)。
|
||||
- 關鍵字佇列、`generate-next`、Tavily 研究、供應商設定 UI 行為不變。
|
||||
- 唔改關鍵字 discovery/排程生成(維持唔做)。
|
||||
- `config.ts`、其他太陽能 seed 內容唔郁。
|
||||
|
||||
---
|
||||
|
||||
## 2. 提示詞模型(結構化欄位)
|
||||
|
||||
沿用現有「結構化欄位」做法(同 webtemplate 一致),唔引入完整可編輯 prompt 模板:
|
||||
|
||||
| 欄位 | 來源 | 角色 |
|
||||
|---|---|---|
|
||||
| `ai_context_prompt` | 後台 | 角色/寫作風格/語氣/目標受眾(可寫行業) |
|
||||
| `ai_business_context` | 後台 | 公司事實(grounding):公司名稱/行業/業務範圍/主要地區/主要產品服務/目標客群/特色定位 |
|
||||
| (SEO/GEO 規則 + 輸出格式) | `ai.ts` code | 通用、唔綁行業,維持穩定輸出 |
|
||||
|
||||
`buildSystemPrompt` 組合順序維持:`contextPrompt → businessContext → research → 通用任務指示`。
|
||||
|
||||
通用任務指示(取代原硬編碼角色):
|
||||
|
||||
```
|
||||
你係一位專業嘅 SEO 內容寫手。請根據上面嘅公司背景同寫作風格,
|
||||
就以下主題寫一篇 800–1200 字嘅繁體中文(香港廣東話書面語)博客文章。
|
||||
主題:<topic>
|
||||
要求:
|
||||
- 標題要放焦點關鍵字,首 100 字內再出現一次,關鍵字密度約 1–2%。
|
||||
- 用 ## / ### 分段,段落清晰易讀。
|
||||
- 開頭先畀一段總結(summary-first),再展開。
|
||||
- 內容要實用、準確,符合公司業務;唔好作出未經證實嘅承諾或數字。
|
||||
- 適合 SEO 同 AI 搜尋(GEO):用問答式小標題、必要時用列表。
|
||||
輸出格式(必須嚴格跟隨,唔要加額外說明):
|
||||
TITLE: <文章標題>
|
||||
CONTENT:
|
||||
<Markdown 正文,唔需要重複標題>
|
||||
META_DESC: <160 字以內 SEO 描述>
|
||||
```
|
||||
|
||||
語言維持「繁體中文(香港)」(本網站只做中文);如需調整語言/語氣,經 `ai_context_prompt` 指定。
|
||||
|
||||
---
|
||||
|
||||
## 3. DeepSeek 供應商
|
||||
|
||||
### 3.1 預設值
|
||||
|
||||
| Key | 舊值 | 新值 |
|
||||
|---|---|---|
|
||||
| `ai_base_url` | `https://api.deepinfra.com/v1/openai` | `https://api.deepseek.com` |
|
||||
| `ai_chat_model` | `deepseek-ai/DeepSeek-V3-0324` | `deepseek-flash` |
|
||||
|
||||
DeepSeek base URL 係 OpenAI 相容,現有 `callChat` 會自動接 `/chat/completions`,無需改 endpoint 邏輯。
|
||||
|
||||
模型選擇:`deepseek-flash`(DeepSeek-V4.1-Flash)——快、平、支援繁體中文,適合每日 SEO blog。後台仍可自行改為 `deepseek-v4-pro` 或其他供應商。
|
||||
|
||||
### 3.2 思考模式
|
||||
|
||||
DeepSeek 思考模式**預設開啟**(effort `high`),會令:`temperature` 失效、reasoning token 佔用 `max_tokens`(有機會正文被截斷)。
|
||||
|
||||
決策:**生成時關閉思考模式**。
|
||||
- `callChat` 只在 `baseUrl` 含 `deepseek.com` 時加 `thinking: { type: "disabled" }`,保留 `temperature: 0.7`。
|
||||
- 其他 OpenAI 相容供應商**唔會**收到非標準 `thinking` 參數,維持兼容。
|
||||
- `max_tokens` 由 `2200` 提升到 `4000`(中文 800–1200 字連 Markdown 需要)。
|
||||
|
||||
### 3.3 現行已部署 D1
|
||||
|
||||
`site_settings` 由 DB 讀取,code 預設只在 key 缺失時生效。已部署 DB 仍有舊 deepinfra 值,處理方式:
|
||||
|
||||
- **部署後手動**去 `/admin/ai` 改 Base URL+模型;`AI_API_KEY` secret 換成 DeepSeek key(`wrangler secret put AI_API_KEY`)。
|
||||
- **唔重跑 `db:seed`**:seed 用 `INSERT OR REPLACE`,會覆蓋所有後台改過嘅設定。
|
||||
|
||||
---
|
||||
|
||||
## 4. 檔案改動
|
||||
|
||||
| 檔案 | 改動 |
|
||||
|---|---|
|
||||
| `src/lib/ai.ts` | 通用化 `buildSystemPrompt`;預設 baseUrl/model 改 DeepSeek;`callChat` 加 conditional thinking disabled、`max_tokens: 4000` |
|
||||
| `src/schemas/ai.ts` | `ai_base_url` / `ai_chat_model` 預設值同步 |
|
||||
| `migrations/seed.sql` | `ai_base_url` / `ai_chat_model` 改預設;`ai_business_context` 改通用格式範本 |
|
||||
| `src/pages/admin/ai.astro` | 模型/URL 預設值同步;公司背景+寫作風格加 placeholder/格式說明 |
|
||||
| `src/lib/AGENTS.md`、根 `AGENTS.md` | 更新 AI Blog 合約描述(DeepSeek 預設、通用提示詞) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 驗證
|
||||
|
||||
- `npm run build`(唯一 build 驗證)。
|
||||
- `npm run dev`(:4321):`.dev.vars` 填 DeepSeek `AI_API_KEY` → 登入 `/admin/ai` → 用通用公司背景 + 一個主題生成,確認:
|
||||
1. 草稿成功寫入並 redirect 去編輯頁;
|
||||
2. 唔再出現太陽能字眼(除非公司背景自己寫);
|
||||
3. 無 timeout、正文完整(未被 max_tokens 截斷)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 決策記錄
|
||||
|
||||
- 提示詞採「結構化欄位」而唔係完整可編輯 prompt 模板:保輸出格式穩定、最貼近 webtemplate、改動最少(YAGNI)。
|
||||
- 模型預設 `deepseek-flash`:成本/速度平衡;`deepseek-v4-pro` 貴約 3–4 倍。
|
||||
- 關閉思考模式:格式最穩定、最快、最便宜;temperature 生效。
|
||||
+9
-3
@@ -23,10 +23,16 @@ INSERT OR REPLACE INTO site_settings (key, value, updated_at) VALUES
|
||||
|
||||
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_base_url', 'https://api.deepseek.com', strftime('%s','now')*1000),
|
||||
('ai_chat_model', 'deepseek-flash', strftime('%s','now')*1000),
|
||||
('ai_context_prompt', '用香港繁體中文(廣東話書面語)書寫,語氣專業、親切、務實,避免誇大。', strftime('%s','now')*1000),
|
||||
('ai_business_context', '盈豐太陽能工程有限公司專營香港村屋太陽能系統:現場評估、合法鋁合金支架、代辦中電/港燈上網電價(FiT)申請、專業安裝及驗收認證。着重透明報價、專人跟進、安全合規。', strftime('%s','now')*1000),
|
||||
('ai_business_context', '公司名稱:
|
||||
行業:
|
||||
業務範圍:
|
||||
主要地區:
|
||||
主要產品服務:
|
||||
目標客群:
|
||||
特色定位:', strftime('%s','now')*1000),
|
||||
('ai_web_search_enabled', '0', strftime('%s','now')*1000),
|
||||
('ai_web_search_max_results', '5', strftime('%s','now')*1000);
|
||||
|
||||
|
||||
+3
-1
@@ -19,8 +19,10 @@
|
||||
- 密碼同 session 比對用 `safeEqual`(constant-time),改動要保留防 timing attack 嘅做法。
|
||||
- **`form.ts`**:`parseForm(formData, schema)` 將 `FormData` 抽成物件交畀 Zod 驗證;回 `{ ok: true, data }` 或 `{ ok: false, errors }`(field → 訊息)。admin 表單一律經呢度,schema 喺 `src/schemas/`。
|
||||
- **`ai.ts`**:AI 生成 Blog 流程。
|
||||
- `getAiSettings(db)` 由 `site_settings` 砌出供應商設定(enabled / baseUrl / model / context / web search)。
|
||||
- `getAiSettings(db)` 由 `site_settings` 砌出供應商設定(enabled / baseUrl / model / context / web search)。**預設供應商係 DeepSeek 官方**(`https://api.deepseek.com`、模型 `deepseek-flash`)。
|
||||
- `buildSystemPrompt` **通用、唔綁行業**:角色/語氣來自 `ai_context_prompt`,公司事實來自 `ai_business_context`;硬編碼只剩通用 SEO/GEO 規則同 `TITLE` / `CONTENT` / `META_DESC` 輸出格式。新增行業內容一律經後台設定,唔好再寫死公司名。
|
||||
- Tavily 上網研究(失敗回空字串,唔中斷)+ OpenAI 相容 `chat/completions`(有 timeout)。
|
||||
- `callChat` 固定 `max_tokens: 4000`;**只在 baseUrl 含 `deepseek.com` 時**加 `thinking: { type: "disabled" }`(DeepSeek 思考模式預設開啟,會令 temperature 失效同佔用 token),其他供應商唔會收到非標準參數。
|
||||
- `parseGeneratedPost(text)` 解析 `TITLE` / `CONTENT` / `META_DESC` 並用 `aiGeneratedPost` schema 驗證。
|
||||
- `generateBlogPost(db, env, input)` 同步生成:`{ mode: "topic" }` 或 `{ mode: "next" }`(攞佇列下一個 pending);一律存 **draft** 並將關鍵字標記 `generated`。缺 `AI_API_KEY` / 未啟用時回 `{ ok: false, error }`,唔會 throw。
|
||||
- **`markdown.ts`**:`renderMarkdown`(`marked`,內容受信任所以**唔 sanitize**)、`slugify`(保留中文)、`autoExcerpt`。
|
||||
|
||||
+6
-5
@@ -20,8 +20,8 @@ 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",
|
||||
baseUrl: s.ai_base_url || "https://api.deepseek.com",
|
||||
model: s.ai_chat_model || "deepseek-flash",
|
||||
contextPrompt: s.ai_context_prompt ?? "",
|
||||
businessContext: s.ai_business_context ?? "",
|
||||
webSearchEnabled: s.ai_web_search_enabled === "1",
|
||||
@@ -61,7 +61,7 @@ function buildSystemPrompt(opts: {
|
||||
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 字嘅繁體中文(香港廣東話書面語)博客文章。
|
||||
parts.push(`你係一位專業嘅 SEO 內容寫手。請根據上面嘅公司背景同寫作風格,就以下主題寫一篇 800–1200 字嘅繁體中文(香港廣東話書面語)博客文章。
|
||||
|
||||
主題:${opts.topic}
|
||||
|
||||
@@ -69,7 +69,7 @@ function buildSystemPrompt(opts: {
|
||||
- 標題要放焦點關鍵字,首 100 字內再出現一次,關鍵字密度約 1–2%。
|
||||
- 用 ## / ### 分段,段落清晰易讀。
|
||||
- 開頭先畀一段總結(summary-first),再展開。
|
||||
- 內容要對香港村屋太陽能實用、準確,唔好作出未經證實嘅承諾或數字。
|
||||
- 內容要實用、準確,符合公司業務;唔好作出未經證實嘅承諾或數字。
|
||||
- 適合 SEO 同 AI 搜尋(GEO):用問答式小標題、必要時用列表。
|
||||
|
||||
輸出格式(必須嚴格跟隨,唔要加額外說明):
|
||||
@@ -87,8 +87,9 @@ async function callChat(settings: AiSettings, apiKey: string, systemPrompt: stri
|
||||
body: JSON.stringify({
|
||||
model: settings.model,
|
||||
messages: [{ role: "user", content: systemPrompt }],
|
||||
max_tokens: 2200,
|
||||
max_tokens: 4000,
|
||||
temperature: 0.7,
|
||||
...(settings.baseUrl.includes("deepseek.com") ? { thinking: { type: "disabled" } } : {}),
|
||||
}),
|
||||
signal: AbortSignal.timeout(90_000),
|
||||
});
|
||||
|
||||
@@ -127,11 +127,11 @@ const secrets = { ai: Boolean(env.AI_API_KEY), tavily: Boolean(env.TAVILY_API_KE
|
||||
</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"} />
|
||||
<input id="ai_chat_model" name="ai_chat_model" type="text" value={settings.ai_chat_model ?? "deepseek-flash"} />
|
||||
</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"} />
|
||||
<input id="ai_base_url" name="ai_base_url" type="text" value={settings.ai_base_url ?? "https://api.deepseek.com"} />
|
||||
<p class="muted" style="margin:6px 0 0">⚠️ 只填信任嘅供應商;AI_API_KEY 會傳送去呢個網址。</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -153,11 +153,21 @@ const secrets = { ai: Boolean(env.AI_API_KEY), tavily: Boolean(env.TAVILY_API_KE
|
||||
</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>
|
||||
<textarea
|
||||
id="ai_context_prompt"
|
||||
name="ai_context_prompt"
|
||||
placeholder="例:用香港繁體中文書寫,語氣專業、親切、務實,避免誇大。目標讀者係⋯⋯"
|
||||
>{settings.ai_context_prompt ?? ""}</textarea>
|
||||
<p class="muted" style="margin:6px 0 0">可寫入角色、行業、語氣同目標受眾;留空會用通用寫手設定。</p>
|
||||
</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>
|
||||
<textarea
|
||||
id="ai_business_context"
|
||||
name="ai_business_context"
|
||||
placeholder={"公司名稱:\n行業:\n業務範圍:\n主要地區:\n主要產品服務:\n目標客群:\n特色定位:"}
|
||||
>{settings.ai_business_context ?? ""}</textarea>
|
||||
<p class="muted" style="margin:6px 0 0">逐項填寫公司事實,AI 會自然融入文章而唔會照抄原文。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions"><button type="submit">儲存 AI 設定</button></div>
|
||||
|
||||
+2
-2
@@ -6,10 +6,10 @@ export const aiSettingsInput = z.object({
|
||||
(v) => (v === "" ? undefined : v),
|
||||
z
|
||||
.url("Base URL 要係有效網址。")
|
||||
.default("https://api.deepinfra.com/v1/openai")
|
||||
.default("https://api.deepseek.com")
|
||||
.refine((u) => /^https?:\/\//i.test(u), "Base URL 只支援 http/https。"),
|
||||
),
|
||||
ai_chat_model: z.string().trim().min(1, "請填模型名稱。").default("deepseek-ai/DeepSeek-V3-0324"),
|
||||
ai_chat_model: z.string().trim().min(1, "請填模型名稱。").default("deepseek-flash"),
|
||||
ai_context_prompt: z.string().default(""),
|
||||
ai_business_context: z.string().default(""),
|
||||
ai_web_search_enabled: z.enum(["1", "0"]).default("0"),
|
||||
|
||||
Reference in New Issue
Block a user