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:
2026-09-12 00:31:26 +08:00
parent fdee1aabd7
commit 81fc515d6e
11 changed files with 321 additions and 19 deletions
+159
View File
@@ -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;
}
+2 -1
View File
@@ -1,2 +1,3 @@
/// <reference types="astro/client" /> /// <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" />
+1 -1
View File
@@ -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。 - **資料層**`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 可重複跑。 - **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 匯出。 - **表單驗證集中在 `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`(雲端)。 - **部署用 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` 產生再貼上,未貼前無法部署。 - **`wrangler.jsonc` 有佔位 id**`database_id` / KV `id` 要先用 `db:create` / `kv:create` 產生再貼上,未貼前無法部署。
- **上線前要改 `astro.config.mjs``site`**sitemap / canonical / OG 全部靠它(現為 `https://example.com`)。 - **上線前要改 `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` **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 2. 想有 AI 生成 Blog 文章功能(參考 webtemplate,但簡化、唔要 Google keyword search
3. 部署到 Cloudflare 嘅流程手續多,想更方便 3. 部署到 Cloudflare 嘅流程手續多,想更方便
- **本 spec 取代**先前 spec 嘅部署一節;其餘架構決策不變。 - **本 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 | 預設 | 說明 | | Key | 預設 | 說明 |
|---|---|---| |---|---|---|
| `ai_enabled` | `1` | 總開關 | | `ai_enabled` | `1` | 總開關 |
| `ai_base_url` | `https://api.deepinfra.com/v1/openai` | OpenAI 相容端點 | | `ai_base_url` | `https://api.deepseek.com` | OpenAI 相容端點DeepSeek 官方) |
| `ai_chat_model` | `deepseek-ai/DeepSeek-V3-0324` | 模型 | | `ai_chat_model` | `deepseek-flash` | 模型 |
| `ai_context_prompt` | `""` | 寫作風格 / 語氣 | | `ai_context_prompt` | `""` | 寫作風格 / 語氣 |
| `ai_business_context` | seed 由公司資料砌 | 公司背景(grounding | | `ai_business_context` | seed 由公司資料砌 | 公司背景(grounding |
| `ai_web_search_enabled` | `0` | Tavily 研究開關 | | `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`(中文 8001200 字連 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` 貴約 34 倍。
- 關閉思考模式:格式最穩定、最快、最便宜;temperature 生效。
+9 -3
View File
@@ -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 INSERT OR REPLACE INTO site_settings (key, value, updated_at) VALUES
('ai_enabled', '1', strftime('%s','now')*1000), ('ai_enabled', '1', strftime('%s','now')*1000),
('ai_base_url', 'https://api.deepinfra.com/v1/openai', strftime('%s','now')*1000), ('ai_base_url', 'https://api.deepseek.com', strftime('%s','now')*1000),
('ai_chat_model', 'deepseek-ai/DeepSeek-V3-0324', strftime('%s','now')*1000), ('ai_chat_model', 'deepseek-flash', strftime('%s','now')*1000),
('ai_context_prompt', '用香港繁體中文(廣東話書面語)書寫,語氣專業、親切、務實,避免誇大。', 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_enabled', '0', strftime('%s','now')*1000),
('ai_web_search_max_results', '5', strftime('%s','now')*1000); ('ai_web_search_max_results', '5', strftime('%s','now')*1000);
+3 -1
View File
@@ -19,8 +19,10 @@
- 密碼同 session 比對用 `safeEqual`constant-time),改動要保留防 timing attack 嘅做法。 - 密碼同 session 比對用 `safeEqual`constant-time),改動要保留防 timing attack 嘅做法。
- **`form.ts`**`parseForm(formData, schema)``FormData` 抽成物件交畀 Zod 驗證;回 `{ ok: true, data }``{ ok: false, errors }`field → 訊息)。admin 表單一律經呢度,schema 喺 `src/schemas/` - **`form.ts`**`parseForm(formData, schema)``FormData` 抽成物件交畀 Zod 驗證;回 `{ ok: true, data }``{ ok: false, errors }`field → 訊息)。admin 表單一律經呢度,schema 喺 `src/schemas/`
- **`ai.ts`**AI 生成 Blog 流程。 - **`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)。 - 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 驗證。 - `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。 - `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` - **`markdown.ts`**`renderMarkdown``marked`,內容受信任所以**唔 sanitize**)、`slugify`(保留中文)、`autoExcerpt`
+6 -5
View File
@@ -20,8 +20,8 @@ export async function getAiSettings(db: Db): Promise<AiSettings> {
const s = await getSettings(db); const s = await getSettings(db);
return { return {
enabled: s.ai_enabled !== "0", enabled: s.ai_enabled !== "0",
baseUrl: s.ai_base_url || "https://api.deepinfra.com/v1/openai", baseUrl: s.ai_base_url || "https://api.deepseek.com",
model: s.ai_chat_model || "deepseek-ai/DeepSeek-V3-0324", model: s.ai_chat_model || "deepseek-flash",
contextPrompt: s.ai_context_prompt ?? "", contextPrompt: s.ai_context_prompt ?? "",
businessContext: s.ai_business_context ?? "", businessContext: s.ai_business_context ?? "",
webSearchEnabled: s.ai_web_search_enabled === "1", webSearchEnabled: s.ai_web_search_enabled === "1",
@@ -61,7 +61,7 @@ function buildSystemPrompt(opts: {
if (opts.contextPrompt) parts.push(opts.contextPrompt); if (opts.contextPrompt) parts.push(opts.contextPrompt);
if (opts.businessContext) parts.push(`## 公司背景資料\n${opts.businessContext}`); if (opts.businessContext) parts.push(`## 公司背景資料\n${opts.businessContext}`);
if (opts.research) parts.push(`## 網上研究資料(只作參考,唔好照抄)\n${opts.research}`); if (opts.research) parts.push(`## 網上研究資料(只作參考,唔好照抄)\n${opts.research}`);
parts.push(`你係香港村屋太陽能公司「盈豐太陽能」嘅 SEO 內容寫手。請就以下主題寫一篇 800–1200 字嘅繁體中文(香港廣東話書面語)博客文章。 parts.push(`你係一位專業嘅 SEO 內容寫手。請根據上面嘅公司背景同寫作風格,就以下主題寫一篇 800–1200 字嘅繁體中文(香港廣東話書面語)博客文章。
主題:${opts.topic} 主題:${opts.topic}
@@ -69,7 +69,7 @@ function buildSystemPrompt(opts: {
- 標題要放焦點關鍵字,首 100 字內再出現一次,關鍵字密度約 1–2%。 - 標題要放焦點關鍵字,首 100 字內再出現一次,關鍵字密度約 1–2%。
- 用 ## / ### 分段,段落清晰易讀。 - 用 ## / ### 分段,段落清晰易讀。
- 開頭先畀一段總結(summary-first),再展開。 - 開頭先畀一段總結(summary-first),再展開。
- 內容要對香港村屋太陽能實用、準確,唔好作出未經證實嘅承諾或數字。 - 內容要實用、準確,符合公司業務;唔好作出未經證實嘅承諾或數字。
- 適合 SEO 同 AI 搜尋(GEO):用問答式小標題、必要時用列表。 - 適合 SEO 同 AI 搜尋(GEO):用問答式小標題、必要時用列表。
輸出格式(必須嚴格跟隨,唔要加額外說明): 輸出格式(必須嚴格跟隨,唔要加額外說明):
@@ -87,8 +87,9 @@ async function callChat(settings: AiSettings, apiKey: string, systemPrompt: stri
body: JSON.stringify({ body: JSON.stringify({
model: settings.model, model: settings.model,
messages: [{ role: "user", content: systemPrompt }], messages: [{ role: "user", content: systemPrompt }],
max_tokens: 2200, max_tokens: 4000,
temperature: 0.7, temperature: 0.7,
...(settings.baseUrl.includes("deepseek.com") ? { thinking: { type: "disabled" } } : {}),
}), }),
signal: AbortSignal.timeout(90_000), signal: AbortSignal.timeout(90_000),
}); });
+14 -4
View File
@@ -127,11 +127,11 @@ const secrets = { ai: Boolean(env.AI_API_KEY), tavily: Boolean(env.TAVILY_API_KE
</div> </div>
<div> <div>
<label for="ai_chat_model">模型</label> <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>
<div> <div>
<label for="ai_base_url">OpenAI 相容 Base URL</label> <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> <p class="muted" style="margin:6px 0 0">⚠️ 只填信任嘅供應商;AI_API_KEY 會傳送去呢個網址。</p>
</div> </div>
<div> <div>
@@ -153,11 +153,21 @@ const secrets = { ai: Boolean(env.AI_API_KEY), tavily: Boolean(env.TAVILY_API_KE
</div> </div>
<div style="grid-column:1/-1"> <div style="grid-column:1/-1">
<label for="ai_context_prompt">寫作風格 / 語氣</label> <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>
<div style="grid-column:1/-1"> <div style="grid-column:1/-1">
<label for="ai_business_context">公司背景資料(grounding</label> <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> </div>
<div class="actions"><button type="submit">儲存 AI 設定</button></div> <div class="actions"><button type="submit">儲存 AI 設定</button></div>
+2 -2
View File
@@ -6,10 +6,10 @@ export const aiSettingsInput = z.object({
(v) => (v === "" ? undefined : v), (v) => (v === "" ? undefined : v),
z z
.url("Base URL 要係有效網址。") .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。"), .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_context_prompt: z.string().default(""),
ai_business_context: z.string().default(""), ai_business_context: z.string().default(""),
ai_web_search_enabled: z.enum(["1", "0"]).default("0"), ai_web_search_enabled: z.enum(["1", "0"]).default("0"),