commit 813276d78a39f0f1c0ac0d2455d46b5fb93d5563 Author: 王性驊 Date: Sun Jun 21 20:50:31 2026 +0800 feat init diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e145500 --- /dev/null +++ b/.env.example @@ -0,0 +1,64 @@ +# AI Providers(也可在設定頁填入,設定頁優先) +XAI_API_KEY= +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +GOOGLE_GENERATIVE_AI_API_KEY= +OPENCODE_GO_API_KEY= + +# Playwright(可選) +PLAYWRIGHT_HEADLESS=true +# 瀏覽器海巡平行數(預設 2、最高 2;遇到限流會立即停止) +THREADS_BROWSER_CONCURRENCY=2 +# 真人操作延遲倍率(預設 1;若帳號較新或曾被限流可調成 1.5~2) +THREADS_HUMAN_DELAY_MULTIPLIER=1 +# 每次最多 4 個搜尋/帳號任務、每個任務最多 12 篇 +CRAWLER_MAX_TASKS_PER_SCAN=4 +CRAWLER_MAX_POSTS_PER_TASK=12 +# 每帳號每日最多開啟 40 個 Threads 搜尋頁 +CRAWLER_DAILY_PAGE_LIMIT=40 +# 偵測到 403/429/checkpoint 後暫停瀏覽器爬蟲(分鐘) +CRAWLER_BLOCK_COOLDOWN_MINUTES=180 +# 設為 true 強制開啟瀏覽器 debug(也可在設定頁開關) +# THREADS_DEBUG=true +# PLAYWRIGHT_SLOW_MO=250 + +# ── 搜尋 Provider(僅 Threads API / Brave / 爬蟲)── + +# Threads API(主力) +THREADS_API_ENABLED=true +THREADS_ACCESS_TOKEN= +THREADS_API_BASE_URL= +THREADS_QUERY_LIMIT_PER_DAY=2200 +# THREADS_SEARCH_CACHE_TTL=15m + +# Brave Search API(MVP 過渡/high priority fallback) +# https://api-dashboard.search.brave.com/ +BRAVE_SEARCH_ENABLED=true +BRAVE_SEARCH_API_KEY= +BRAVE_SEARCH_BASE_URL=https://api.search.brave.com/res/v1/web/search +BRAVE_DAILY_LIMIT=30 +BRAVE_RESULT_LIMIT=10 +BRAVE_CACHE_TTL=4h +# 單次海巡 Brave 查詢上限(預設 8) +# SCAN_BRAVE_MAX_QUERIES=8 + +# 瀏覽器爬蟲(補漏/詳細內容) +CRAWLER_ENABLED=true +# CRAWLER_RATE_LIMIT= +CRAWLER_CACHE_TTL=1h + +# 以下舊搜尋 API 已停用,請勿再設定: +# SERPAPI_API_KEY, SERPER_API_KEY, GOOGLE_SEARCH_API_KEY, +# GOOGLE_CSE_API_KEY, GOOGLE_CSE_CX, BING_SEARCH_API_KEY, +# TAVILY_API_KEY, EXA_API_KEY, SEARXNG_BASE_URL, DUCKDUCKGO_ENABLED + +# DB +DATABASE_URL="file:./dev.db" + +# Threads 官方 API(僅在 .env 設定,網頁不提供填寫) +# 在 Meta 開發者後台建立 Threads App,並加入 Redirect URI: +# {APP_URL}/api/threads/oauth/callback +THREADS_APP_ID= +THREADS_APP_SECRET= +# 本機開發若要用 API 發布含圖貼文,需設成 Meta 可存取的公開網址(如 ngrok) +APP_URL=http://localhost:3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..381ad8b --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env +.env*.local + +# database +/prisma/dev.db +/prisma/dev.db-journal +*.db +*.db-journal + +# playwright +/playwright/.auth/ +/storage/ +/data/ + +# typescript +next-env.d.ts \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..dc81e55 --- /dev/null +++ b/Makefile @@ -0,0 +1,95 @@ +# 巡樓 Haixun — 快速啟動與程序管理 +# 需求:Node.js 20+、npm、pm2(npm i -g pm2) + +SHELL := /bin/bash +ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) +PORT ?= 3000 + +PM2 := $(shell command -v pm2 2>/dev/null) + +.PHONY: help init db-init install build up start dev stop restart status logs down \ + playwright-setup save + +help: + @echo "巡樓 Haixun — 常用指令" + @echo "" + @echo " 初始化" + @echo " make init 首次設定(安裝依賴 + 建立 .env + 初始化 DB)" + @echo " make db-init 僅同步 Prisma schema 到資料庫" + @echo " make install 僅 npm install" + @echo "" + @echo " 啟動(PM2)" + @echo " make start build 後以 PM2 啟動 web + worker(生產)" + @echo " make up 不 build,直接 PM2 啟動 web + worker" + @echo " make dev PM2 啟動開發伺服器(next dev,不含 worker)" + @echo " make stop 停止所有 Haixun 程序" + @echo " make restart 重啟所有 Haixun 程序" + @echo " make down 停止並移除 PM2 程序" + @echo " make status 查看 PM2 狀態" + @echo " make logs 查看 PM2 日誌(web + worker)" + @echo " make save 儲存 PM2 程序列表(開機自動啟動用)" + @echo "" + @echo " 其他" + @echo " make build next build" + @echo " make playwright-setup 安裝 Chromium 與 Playwright 依賴" + @echo "" + @echo " 環境變數:PORT=$(PORT)(預設 3000)" + +init: + @bash "$(ROOT)/scripts/init.sh" + +db-init: + @test -f "$(ROOT)/.env" || (cp "$(ROOT)/.env.example" "$(ROOT)/.env" && echo "已建立 .env") + @cd "$(ROOT)" && npm run db:generate && npm run db:push + @echo "資料庫 schema 已同步" + +install: + @cd "$(ROOT)" && npm install + +build: + @cd "$(ROOT)" && npm run build + +check-pm2: +ifndef PM2 + @echo "錯誤:找不到 pm2,請執行:npm install -g pm2" >&2 + @exit 1 +endif + +up: check-pm2 + @chmod +x "$(ROOT)/scripts/"pm2-*.sh "$(ROOT)/scripts/init.sh" + @cd "$(ROOT)" && PORT=$(PORT) pm2 start ecosystem.config.cjs --only haixun-web,haixun-worker + @echo "已啟動:haixun-web (http://localhost:$(PORT))、haixun-worker" + @echo "查看狀態:make status" + +start: build up + +dev: check-pm2 + @chmod +x "$(ROOT)/scripts/"pm2-*.sh + @cd "$(ROOT)" && PORT=$(PORT) pm2 start ecosystem.config.cjs --only haixun-web-dev + @echo "開發伺服器:http://localhost:$(PORT)" + @echo "查看日誌:pm2 logs haixun-web-dev" + +stop: check-pm2 + @cd "$(ROOT)" && pm2 stop haixun-web haixun-worker haixun-web-dev 2>/dev/null || true + @echo "已停止" + +restart: check-pm2 + @cd "$(ROOT)" && pm2 restart haixun-web haixun-worker 2>/dev/null || $(MAKE) up + @echo "已重啟" + +down: check-pm2 + @cd "$(ROOT)" && pm2 delete haixun-web haixun-worker haixun-web-dev 2>/dev/null || true + @echo "已移除 PM2 程序" + +status: check-pm2 + @pm2 status haixun-web haixun-worker haixun-web-dev 2>/dev/null || pm2 status + +logs: check-pm2 + @pm2 logs haixun-web haixun-worker --lines 100 + +save: check-pm2 + @pm2 save + @echo "PM2 程序列表已儲存(搭配 pm2 startup 可開機自啟)" + +playwright-setup: + @cd "$(ROOT)" && npm run playwright:setup \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..607468a --- /dev/null +++ b/README.md @@ -0,0 +1,207 @@ +# 巡樓(Haixun Master)— Threads AI 經營與獲客工作台 + +用 AI 在 Threads(脆)上經營帳號,支援多帳號、混合資料來源(官方 API / 瀏覽器爬蟲)與自動化。 + +## 兩條核心流程 + +| 流程 | 說明 | 現階段建議 | +|------|------|------------| +| **流程 A — 風格複製發文** | 海巡爆文與留言 → AI 學風格 → 草稿 → 審核 → 發文 | Chrome 同步 + 爬蟲 | +| **流程 B — 產品獲客** | 找潛在客群貼文 → 生成獲客留言 → 回覆自己貼文 → 追成效 | 需 Meta 官方 API | + +## 技術棧 + +- Next.js 15 + TypeScript + Tailwind CSS +- SQLite + Prisma +- Playwright(瀏覽器海巡、爬留言、發文) +- Meta Threads 官方 API(發文、獲客留言、成效;需 OAuth) +- Chrome 擴充(從本機 Chrome 同步 session 到遠端 server) +- Vercel AI SDK(OpenCode Go / Grok / OpenAI / Claude / Gemini) + +## 快速開始 + +```bash +npm install +npm run playwright:setup # Chromium + Linux 依賴 + +cp .env.example .env +# 填入 OPENCODE_GO_API_KEY 等 + +npm run db:push +npm run dev +``` + +開啟 http://localhost:3000 + +遠端 Linux server 部署時另需: + +```bash +PLAYWRIGHT_HEADLESS=true npm run start +npm run worker # 若要自動化排程 +``` + +## 三層設定分離 + +| 層級 | 在哪裡設定 | 範圍 | 內容 | +|------|-----------|------|------| +| **巡樓使用者** | 設定 `/settings` | 登入者是誰就是誰 | AI API Key、預設模型、產文偏好 | +| **巡樓使用者** | 設定 `/settings` | 全 Threads 帳號共用 | Meta App ID/Secret | +| **Threads 經營帳號** | 連線設定 `/connections` | 每帳號各一份 | 連線預設、Chrome 同步、OAuth token | +| **Threads 經營帳號** | 帳號策略 `/accounts` | 每帳號各一份 | 人設、受眾、定位策略 | + +> AI Key 跟你有幾個 Threads 帳號無關 — 切換經營帳號不會換 AI Key。 + +## 設定頁:連線與資料流程 + +每帳號的連線預設與 Chrome 同步在 **連線設定**(`/connections`);AI Key 等在 **設定**(`/settings`)。 + +### 連線預設(三種常用模式) + +| 預設 | 海巡 | 留言 | 發文 | 適用情境 | +|------|------|------|------|----------| +| **Chrome 同步** | 瀏覽器 | 瀏覽器爬取 | 瀏覽器 | 現階段全爬蟲、要留言學風格 | +| **API Key 優先** | Meta API | 不爬 | Meta API | 已申請官方 API、流程 B | +| **混合模式** | Meta API | 瀏覽器爬取 | 瀏覽器 | API 海巡 + 留言素材 | + +設定頁會即時顯示「目前流程預覽」,說明海巡、留言、發文各走哪條路。 + +### 底層開關(進階自訂) + +| 開關 | 作用 | +|------|------| +| `searchViaApi` | 海巡優先 Meta keyword search | +| `publishViaApi` | 發文優先 Meta API | +| `devMode` | 允許 Playwright 瀏覽器海巡與爬留言 | +| `scrapeReplies` | 是否抓他人貼文留言(需 devMode) | +| `repliesPerPost` | 每篇熱門文抓幾則留言 | +| `publishHeaded` | 發文時是否顯示瀏覽器視窗 | +| `playwrightDebug` | 保留 Playwright 除錯截圖 | + +### Chrome Session 同步(遠端 server 必備) + +服務跑在 Linux 無頭 server 時,**無法**在 server 上直接登入 Threads。 + +改用 Chrome 擴充(在**連線設定**頁操作): + +1. Chrome → `chrome://extensions` → 開發人員模式 → 載入 `extension/haixun-threads-sync` +2. 擴充選項填入 server 網址(例如 `https://your-server.com`) +3. 在 Chrome 登入 threads.com +4. 巡樓側欄切換到目標帳號 +5. 連線設定頁按「從 Chrome 同步到目前帳號」 + +擴充會讀取 Chrome 的 Threads/Instagram cookies,轉成 Playwright `storageState` 寫入 server DB。 + +> 不要同時在本機 Chrome 與 server Playwright 登入同一 Threads 帳號,會互相踢出。 + +### Meta App 憑證(非 AI Key) + +在設定頁填 **Threads App ID** + **App Secret**(全帳號共用),再到連線設定頁或側欄為**每個帳號**各做一次 OAuth 綁定。 + +完整申請步驟見 [docs/threads-api-setup.md](docs/threads-api-setup.md)。 + +## 使用流程 + +### 流程 A(現階段:Chrome 同步) + +1. **連線設定** `/connections` → 安裝擴充並同步 Chrome session +2. **設定** `/settings` → 填 AI Key +3. **海巡** `/matrix` → 開始海巡 → 生成草稿 → 審核 → 發布 +4. **成效紀錄** `/published` → 查看發布結果 + +### 流程 B(API 獲客) + +1. **連線設定** `/connections` → 綁定 Threads API OAuth +2. **設定** `/settings` → 填 AI Key +3. **找 TA** `/outreach` → 挖掘受眾 → 生成留言 → 發布 +4. **互動經營** `/engagement` → 同步留言 → 生成回覆 → 發布 +5. **成效紀錄** `/published` → 追蹤成效 + +## 多帳號模型 + +每個「經營帳號」各自有策略、主題、草稿、session 與 API token: + +- **瀏覽器 session**:Chrome 擴充同步到 `Account.storageState`(每帳號各一次) +- **官方 API token**:側欄 OAuth 授權(每帳號各一把,共用一組 App ID/Secret) + +側欄可「新增經營帳號」→ 切換帳號 → 對該帳號同步 Chrome session。 + +## 資料抓取邏輯(程式行為) + +``` +海巡 + ├─ searchViaApi + 帳號有 OAuth → Meta keyword search + ├─ 失敗或未開 → devMode 開 → Playwright 搜尋 + └─ 記錄 Scan.searchSource = "api" | "browser" + +留言(top 12 篇) + ├─ scrapeReplies + devMode + 有 session → Playwright 爬留言 + └─ 否則略過(API 模式無法讀他人留言) + +發文 + ├─ publishViaApi + OAuth → Meta API + └─ 否則 / 失敗 → Playwright + storageState +``` + +## 自動化 + +到 **自動化** 頁設定定時任務(需 `npm run worker`): + +| 任務 | 流程 | +|------|------| +| 自動海巡 | A + B | +| 自動生成草稿 | A | +| 自動發文 | A | +| 自動獲客留言 | B(需 Meta API) | +| 自動回覆留言 | B(需 Meta API) | + +## 環境變數 + +| 變數 | 說明 | +|------|------| +| `OPENCODE_GO_API_KEY` | OpenCode Go(預設 AI) | +| `XAI_API_KEY` / `OPENAI_API_KEY` 等 | 其他 AI provider(可選) | +| `PLAYWRIGHT_HEADLESS` | `true`(server 預設)或 `false`(本機除錯) | +| `DATABASE_URL` | SQLite 路徑,預設 `file:./dev.db` | +| `THREADS_APP_ID` / `THREADS_APP_SECRET` | 也可在設定頁填,不必寫 .env | +| `APP_URL` | 對外網址,OAuth 與含圖 API 發文用 | + +## 專案結構 + +``` +app/ # Next.js 頁面與 API + (dashboard)/matrix/ # 海巡 — 內容矩陣與草稿審核 + (dashboard)/outreach/ # 找 TA — 獲客留言 + (dashboard)/engagement/ # 互動經營 — 留言回覆 + (dashboard)/connections/ # 連線設定 — Chrome 同步、OAuth、搜尋來源 + (dashboard)/automation/ # 自動化排程 + (dashboard)/published/ # 成效紀錄 + (dashboard)/settings/ # 設定 — AI Key、模型、產文偏好 +extension/haixun-threads-sync/ # Chrome 擴充:同步 session +lib/ + threads-api/ # Meta 官方 API + threads-browser/ # Playwright 爬蟲 + services/scan.ts # 海巡編排(API / 瀏覽器) + automation/ # 自動化引擎 +worker/ # cron 排程器 +docs/threads-api-setup.md # Meta API 申請指南 +``` + +## 路線圖 + +| 階段 | 讀資料 | 寫資料 | +|------|--------|--------| +| **現在** | Playwright 爬蟲 + Chrome 同步 | Playwright 發文 | +| **之後** | Apify(留言)+ Meta API(海巡) | Meta API(獲客/回覆/成效) | + +爬蟲模組會保留作 fallback。 + +## 風險與注意事項 + +- **瀏覽器爬蟲違反 Meta ToS**,有封號風險;建議用測試帳號 +- **官方 API 較合規**,但讀不了他人貼文留言 +- `storageState` 與 token 等同密碼,勿 commit `.env` 或 `*.db` +- Threads 貼文 ≤ 500 字 + +## License + +MIT — 使用風險自負。 \ No newline at end of file diff --git a/app/(dashboard)/accounts/page.tsx b/app/(dashboard)/accounts/page.tsx new file mode 100644 index 0000000..e012e42 --- /dev/null +++ b/app/(dashboard)/accounts/page.tsx @@ -0,0 +1,646 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { flushSync } from "react-dom"; +import { + Loader2, + ScanText, + MessageCircle, + Plus, + Send, + Sparkles, + UserRound, + WandSparkles, + X, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { EmptyState } from "@/components/layout/empty-state"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { PageHeader } from "@/components/layout/page-header"; +import { useJobs } from "@/components/layout/jobs-provider"; +import { JobProgressPanel } from "@/components/job-progress-panel"; +import { InlineAlert } from "@/components/ui/inline-alert"; +import { Textarea } from "@/components/ui/textarea"; +import { useActionFeedback } from "@/lib/use-action-feedback"; +import { parseFetchJson } from "@/lib/utils"; +import { + createEmptyStyle8DProfile, + parseStyle8DProfile, + STYLE_8D_KEYS, + STYLE_8D_LABELS, + type StoredStyle8DProfile, + type Style8DKey, +} from "@/lib/types/style-profile"; + +interface Account { + id: string; + username?: string | null; + displayName?: string | null; + persona?: string | null; + styleProfile?: string | null; + styleBenchmark?: string | null; + brief?: string | null; + productBrief?: string | null; + targetAudience?: string | null; + goals?: string | null; +} + +interface AssistantMessage { + role: "user" | "assistant"; + content: string; +} + +type Style8DResult = StoredStyle8DProfile; + +export default function AccountsPage() { + const [account, setAccount] = useState(null); + const [draft, setDraft] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(null); + const [newName, setNewName] = useState(""); + const [assistantOpen, setAssistantOpen] = useState(true); + const [assistantInput, setAssistantInput] = useState(""); + const [assistantBusy, setAssistantBusy] = useState(false); + const [benchmarkUsername, setBenchmarkUsername] = useState(""); + const [styleResult, setStyleResult] = useState(null); + const assistantInputRef = useRef(null); + const { activeJobs } = useJobs(); + const { feedback, clearFeedback, showError, showSuccess } = useActionFeedback(); + const [assistantMessages, setAssistantMessages] = useState([ + { + role: "assistant", + content: "跟我說你想經營的方向,我會把這頁策略欄位先幫你補好。", + }, + ]); + const styleJob = activeJobs.find( + (job) => job.type === "style-8d" && (!draft?.id || job.accountId === draft.id) + ); + const visibleStyle = + styleResult ?? createEmptyStyle8DProfile(benchmarkUsername.replace(/^@/, "").trim()); + + const load = useCallback(async () => { + setLoading(true); + const res = await fetch("/api/accounts"); + const data = await parseFetchJson<{ accounts?: Account[]; activeAccountId?: string }>(res); + const rows = (data.accounts ?? []) as Account[]; + const active = rows.find((row) => row.id === data.activeAccountId) ?? rows[0] ?? null; + setAccount(active); + setDraft(active); + setStyleResult(parseStyle8DProfile(active?.styleProfile)); + setBenchmarkUsername(active?.styleBenchmark ?? ""); + setLoading(false); + }, []); + + useEffect(() => { + load(); + }, [load]); + + useEffect(() => { + function onJobCompleted(event: Event) { + const { job } = (event as CustomEvent).detail as { + job: { type: string; accountId?: string | null; status: string }; + }; + if (job.type === "style-8d" && job.accountId === account?.id && job.status === "completed") { + void load(); + } + } + window.addEventListener("job-completed", onJobCompleted); + return () => window.removeEventListener("job-completed", onJobCompleted); + }, [account?.id, load]); + + async function createAccount() { + if (!newName.trim()) return; + clearFeedback(); + setBusy("create"); + const res = await fetch("/api/accounts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ displayName: newName.trim() }), + }); + let data: { error?: string } = {}; + try { + data = await parseFetchJson(res); + } catch (err) { + setBusy(null); + showError(err instanceof Error ? err.message : "伺服器回應異常", "建立失敗"); + return; + } + setBusy(null); + if (!res.ok) { + showError(data.error ?? "無法建立帳號", "建立失敗"); + return; + } + setNewName(""); + showSuccess("帳號策略已建立"); + load(); + } + + async function save() { + if (!draft) return; + clearFeedback(); + setBusy("save"); + const res = await fetch(`/api/accounts/${draft.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + displayName: draft.displayName, + username: draft.username, + persona: draft.persona, + styleProfile: draft.styleProfile, + styleBenchmark: draft.styleBenchmark, + brief: draft.brief, + productBrief: draft.productBrief, + targetAudience: draft.targetAudience, + goals: draft.goals, + }), + }); + let data: { error?: string } = {}; + try { + data = await parseFetchJson(res); + } catch (err) { + setBusy(null); + showError(err instanceof Error ? err.message : "伺服器回應異常", "儲存失敗"); + return; + } + setBusy(null); + if (!res.ok) { + showError(data.error ?? "無法儲存策略", "儲存失敗"); + return; + } + showSuccess("帳號策略已儲存"); + load(); + } + + function updateDraft(patch: Partial) { + setDraft((prev) => (prev ? { ...prev, ...patch } : prev)); + } + + async function analyzeBenchmark() { + if (!draft || !benchmarkUsername.trim()) return; + clearFeedback(); + setBusy("8d"); + try { + const res = await fetch(`/api/accounts/${draft.id}/style-analysis`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ benchmarkUsername }), + }); + const data = await parseFetchJson<{ error?: string; jobId?: string; message?: string }>(res); + if (!res.ok) { + showError(data.error ?? "無法完成 8D 分析", "8D 分析失敗"); + return; + } + showSuccess(data.message ?? "8D 分析已在背景執行,可自由切換頁面"); + window.dispatchEvent(new Event("haixun:jobs-updated")); + } catch (error) { + showError(error instanceof Error ? error.message : "8D 分析失敗", "8D 分析失敗"); + } finally { + setBusy(null); + } + } + + function updateStyleDimension(key: Style8DKey, summary: string) { + const base = styleResult ?? createEmptyStyle8DProfile(benchmarkUsername.replace(/^@/, "").trim()); + const next: Style8DResult = { + ...base, + analysis: { + ...base.analysis, + [key]: { ...base.analysis[key], summary }, + }, + }; + setStyleResult(next); + updateDraft({ styleProfile: JSON.stringify(next) }); + } + + const clearAssistantInput = useCallback(() => { + setAssistantInput(""); + if (assistantInputRef.current) { + assistantInputRef.current.value = ""; + } + }, []); + + async function askAssistant(preset?: string) { + if (!draft) return; + const instruction = (preset ?? assistantInput).trim(); + if (!instruction || assistantBusy) return; + + flushSync(() => { + if (!preset) clearAssistantInput(); + setAssistantMessages((prev) => [...prev, { role: "user", content: instruction }]); + }); + setAssistantBusy(true); + + const res = await fetch("/api/accounts/strategy-assistant", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + instruction, + current: { + displayName: draft.displayName, + username: draft.username, + brief: draft.brief, + persona: draft.persona, + targetAudience: draft.targetAudience, + productBrief: draft.productBrief, + goals: draft.goals, + style8D: Object.fromEntries( + STYLE_8D_KEYS.map((key) => [key, visibleStyle.analysis[key]?.summary ?? null]) + ), + }, + }), + }); + let data: { + error?: string; + fields?: Partial & { style8D?: Partial> }; + message?: string; + } = {}; + try { + data = await parseFetchJson(res); + } catch (err) { + setAssistantBusy(false); + const message = err instanceof Error ? err.message : "伺服器回應異常"; + setAssistantMessages((prev) => [...prev, { role: "assistant", content: message }]); + return; + } + setAssistantBusy(false); + + if (!res.ok) { + const message = data.error ?? "小幫手暫時無法產生內容"; + setAssistantMessages((prev) => [...prev, { role: "assistant", content: message }]); + return; + } + + const fields = data.fields ?? {}; + const { style8D, ...accountFields } = fields; + let nextStyleResult = styleResult; + if (style8D && Object.values(style8D).some((value) => value?.trim())) { + const base = styleResult ?? createEmptyStyle8DProfile(benchmarkUsername.replace(/^@/, "").trim()); + nextStyleResult = { + ...base, + analysis: { ...base.analysis }, + }; + for (const key of STYLE_8D_KEYS) { + const summary = style8D[key]?.trim(); + if (summary) { + nextStyleResult.analysis[key] = { ...base.analysis[key], summary }; + } + } + setStyleResult(nextStyleResult); + } + setDraft((prev) => + prev + ? { + ...prev, + ...Object.fromEntries( + Object.entries(accountFields).filter(([, value]) => value !== null && value !== undefined) + ), + ...(nextStyleResult ? { styleProfile: JSON.stringify(nextStyleResult) } : {}), + } + : prev + ); + setAssistantMessages((prev) => [ + ...prev, + { + role: "assistant", + content: data.message ?? "我已經依照你的方向補上這頁欄位,你可以再微調後儲存。", + }, + ]); + } + + return ( +
+ + {busy === "save" && } + 儲存策略 + + ) + } + /> + + {feedback && ( + + )} + + {loading ? ( +
+
+
+
+ ) : !draft ? ( + + setNewName(e.target.value)} + placeholder="例如:個人品牌、產品號" + /> + +
+ } + /> + ) : ( +
+ + +
+
+ {account?.displayName ?? account?.username ?? "未命名帳號"} + +
+ 目前帳號 +
+
+ +
+
+ + updateDraft({ displayName: e.target.value })} + placeholder="給自己看的名稱" + /> +
+
+ + updateDraft({ username: e.target.value })} + placeholder="@username" + /> +
+
+ +
+ +