385 lines
15 KiB
TypeScript
385 lines
15 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import Link from "next/link";
|
||
import { Loader2, Package, PlugZap, Settings2, ShieldCheck, UserRound } from "lucide-react";
|
||
import { ApiKeysForm } from "@/components/settings/api-keys-form";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { PageHeader } from "@/components/layout/page-header";
|
||
import { ThemeToggle } from "@/components/theme-toggle";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { InlineAlert } from "@/components/ui/inline-alert";
|
||
import { PROVIDER_OPTIONS } from "@/lib/ai/provider";
|
||
import type { ProviderApiKeys, ProviderId } from "@/lib/ai/keys";
|
||
import { useActionFeedback } from "@/lib/use-action-feedback";
|
||
|
||
interface SettingsData {
|
||
aiProvider: string;
|
||
aiModel: string;
|
||
researchAiProvider?: string | null;
|
||
researchAiModel?: string | null;
|
||
draftsPerScan: number;
|
||
matrixRows: number;
|
||
scanCron: string;
|
||
apiKeys?: ProviderApiKeys;
|
||
apiKeysConfigured?: Partial<Record<ProviderId, boolean>>;
|
||
}
|
||
|
||
export default function SettingsPage() {
|
||
const [settings, setSettings] = useState<SettingsData | null>(null);
|
||
const [apiKeyInputs, setApiKeyInputs] = useState<ProviderApiKeys>({});
|
||
const [saving, setSaving] = useState(false);
|
||
const [loadError, setLoadError] = useState<string | null>(null);
|
||
const { feedback, clearFeedback, showError, showSuccess } = useActionFeedback();
|
||
|
||
async function load() {
|
||
try {
|
||
const res = await fetch("/api/settings");
|
||
const data = (await res.json().catch(() => ({}))) as SettingsData;
|
||
if (!res.ok || !data.aiProvider) {
|
||
setLoadError("無法載入設定,請重新整理頁面");
|
||
return;
|
||
}
|
||
setSettings(data);
|
||
setApiKeyInputs(data.apiKeys ?? {});
|
||
} catch {
|
||
setLoadError("網路連線異常,請重新整理頁面");
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
load();
|
||
}, []);
|
||
|
||
async function handleSave() {
|
||
if (!settings) return;
|
||
clearFeedback();
|
||
setSaving(true);
|
||
try {
|
||
const res = await fetch("/api/settings", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
aiProvider: settings.aiProvider,
|
||
aiModel: settings.aiModel,
|
||
researchAiProvider: settings.researchAiProvider,
|
||
researchAiModel: settings.researchAiModel,
|
||
draftsPerScan: settings.draftsPerScan,
|
||
matrixRows: settings.matrixRows,
|
||
scanCron: settings.scanCron,
|
||
apiKeys: apiKeyInputs,
|
||
}),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
showError(data.error ?? "無法儲存設定", "儲存失敗");
|
||
return;
|
||
}
|
||
setSettings(data);
|
||
showSuccess("設定已儲存");
|
||
} catch {
|
||
showError("網路連線異常,請稍後再試", "儲存失敗");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
if (loadError) {
|
||
return (
|
||
<div>
|
||
<PageHeader eyebrow="SYSTEM" title="設定" description="只留下會直接影響海巡與找 TA 的設定。" />
|
||
<InlineAlert type="error" title="載入失敗" message={loadError} />
|
||
<Button onClick={load} className="mt-4">重試</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!settings) {
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="skeleton h-12 animate-pulse" />
|
||
<div className="skeleton h-48 animate-pulse" />
|
||
<div className="skeleton h-64 animate-pulse" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const provider = PROVIDER_OPTIONS.find((p) => p.value === settings.aiProvider);
|
||
const researchProvider = PROVIDER_OPTIONS.find(
|
||
(p) => p.value === (settings.researchAiProvider ?? settings.aiProvider)
|
||
);
|
||
const currentProviderConfigured = settings.apiKeysConfigured?.[settings.aiProvider as ProviderId];
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
eyebrow="SYSTEM"
|
||
title="設定"
|
||
description="只留下會直接影響海巡與找 TA 的設定。"
|
||
/>
|
||
|
||
{feedback && (
|
||
<InlineAlert
|
||
type={feedback.type}
|
||
title={feedback.title}
|
||
message={feedback.message}
|
||
onDismiss={clearFeedback}
|
||
className="mb-4"
|
||
/>
|
||
)}
|
||
|
||
<div className="space-y-8">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2"><ShieldCheck className="h-4 w-4 text-success" />帳號保護模式</CardTitle>
|
||
<CardDescription>瀏覽器海巡固定採用保守限制;無法保證零風險,遇到平台警告會立即停止。</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||
<div className="rounded-xl bg-muted/70 p-3"><b className="block">單工執行</b><span className="text-xs text-muted-foreground">同時只開一個抓取頁</span></div>
|
||
<div className="rounded-xl bg-muted/70 p-3"><b className="block">每次最多 4 任務</b><span className="text-xs text-muted-foreground">每個任務最多 12 篇</span></div>
|
||
<div className="rounded-xl bg-muted/70 p-3"><b className="block">留言最多 4 × 5</b><span className="text-xs text-muted-foreground">只抓高分貼文</span></div>
|
||
<div className="rounded-xl bg-muted/70 p-3"><b className="block">每日最多 40 頁</b><span className="text-xs text-muted-foreground">限流/驗證即停</span></div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<Card>
|
||
<CardContent className="flex items-center gap-4 p-5">
|
||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10 text-primary"><PlugZap className="h-5 w-5" /></div>
|
||
<div className="min-w-0 flex-1"><p className="font-semibold">連線設定</p><p className="mt-1 text-xs text-muted-foreground">Chrome 同步、Threads API、搜尋來源</p></div>
|
||
<Button asChild variant="outline" size="sm"><Link href="/connections">前往</Link></Button>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="flex items-center gap-4 p-5">
|
||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10 text-primary"><UserRound className="h-5 w-5" /></div>
|
||
<div className="min-w-0 flex-1"><p className="font-semibold">人設設定</p><p className="mt-1 text-xs text-muted-foreground">控制語氣、定位與禁用詞</p></div>
|
||
<Button asChild variant="outline" size="sm"><Link href="/accounts">編輯</Link></Button>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="flex items-center gap-4 p-5">
|
||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10 text-primary"><Package className="h-5 w-5" /></div>
|
||
<div className="min-w-0 flex-1"><p className="font-semibold">品牌與產品</p><p className="mt-1 text-xs text-muted-foreground">找 TA 的置入話術依據</p></div>
|
||
<Button asChild variant="outline" size="sm"><Link href="/products">編輯</Link></Button>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>外觀</CardTitle>
|
||
<CardDescription>深色或淺色,保存在此裝置</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ThemeToggle />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card id="ai-keys">
|
||
<CardHeader>
|
||
<CardTitle>AI API Key</CardTitle>
|
||
<CardDescription>
|
||
所有 Threads 經營帳號共用。目前選用的服務
|
||
{currentProviderConfigured ? " 已設定 key。" : " 尚未設定 key。"}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ApiKeysForm
|
||
values={apiKeyInputs}
|
||
configured={settings.apiKeysConfigured ?? {}}
|
||
onChange={(providerId, value) =>
|
||
setApiKeyInputs((prev) => ({ ...prev, [providerId]: value }))
|
||
}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>預設模型</CardTitle>
|
||
<CardDescription>草稿、留言回覆與獲客留言使用的 AI 模型。</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label>AI 服務</Label>
|
||
<Select
|
||
value={settings.aiProvider}
|
||
onValueChange={(value) => {
|
||
const nextProvider = PROVIDER_OPTIONS.find((option) => option.value === value);
|
||
setSettings({
|
||
...settings,
|
||
aiProvider: value,
|
||
aiModel: nextProvider?.models[0] ?? settings.aiModel,
|
||
});
|
||
}}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{PROVIDER_OPTIONS.map((option) => (
|
||
<SelectItem key={option.value} value={option.value}>
|
||
{option.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>預設模型</Label>
|
||
<Select
|
||
value={settings.aiModel}
|
||
onValueChange={(value) => setSettings({ ...settings, aiModel: value })}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{(provider?.models ?? []).map((model) => (
|
||
<SelectItem key={model} value={model}>
|
||
{model}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<div className="rounded-lg border border-border bg-muted p-4">
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label>研究地圖 AI 服務</Label>
|
||
<Select
|
||
value={settings.researchAiProvider ?? settings.aiProvider}
|
||
onValueChange={(value) => {
|
||
const nextProvider = PROVIDER_OPTIONS.find((option) => option.value === value);
|
||
setSettings({
|
||
...settings,
|
||
researchAiProvider: value,
|
||
researchAiModel:
|
||
nextProvider?.models[0] ?? settings.researchAiModel ?? settings.aiModel,
|
||
});
|
||
}}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{PROVIDER_OPTIONS.map((option) => (
|
||
<SelectItem key={option.value} value={option.value}>
|
||
{option.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>研究地圖模型</Label>
|
||
<Select
|
||
value={settings.researchAiModel ?? settings.aiModel}
|
||
onValueChange={(value) => setSettings({ ...settings, researchAiModel: value })}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{(researchProvider?.models ?? []).map((model) => (
|
||
<SelectItem key={model} value={model}>
|
||
{model}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>產文偏好</CardTitle>
|
||
<CardDescription>影響每次海巡後生成草稿的數量與排程。</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="grid gap-4 sm:grid-cols-3">
|
||
<div className="space-y-2">
|
||
<Label>每次生成草稿數</Label>
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={10}
|
||
value={settings.draftsPerScan}
|
||
onChange={(event) =>
|
||
setSettings({
|
||
...settings,
|
||
draftsPerScan: parseInt(event.target.value, 10) || 4,
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>內容規劃列數</Label>
|
||
<Input
|
||
type="number"
|
||
min={3}
|
||
max={20}
|
||
value={settings.matrixRows}
|
||
onChange={(event) =>
|
||
setSettings({
|
||
...settings,
|
||
matrixRows: parseInt(event.target.value, 10) || 7,
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>海巡排程(cron)</Label>
|
||
<Input
|
||
value={settings.scanCron}
|
||
onChange={(event) => setSettings({ ...settings, scanCron: event.target.value })}
|
||
placeholder="0 9 * * *"
|
||
className="font-mono"
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>查證搜尋</CardTitle>
|
||
<CardDescription>知識型貼文發布前會自動查證。</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2 text-[13px] text-muted-foreground">
|
||
<p>
|
||
海巡搜尋來源可在「<strong>連線設定</strong>」選擇:混合模式、僅 Threads API、僅
|
||
Brave、僅爬蟲,或自訂組合。
|
||
</p>
|
||
<p>
|
||
在 <code>.env</code> 設定 <code>BRAVE_SEARCH_API_KEY</code>(
|
||
<a
|
||
href="https://api-dashboard.search.brave.com/"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="underline"
|
||
>
|
||
Brave Search API
|
||
</a>
|
||
)。置入海巡預設最多 8 次 Brave 查詢,可用 <code>SCAN_BRAVE_MAX_QUERIES</code> 調整。
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Button onClick={handleSave} disabled={saving} size="lg" className="w-full sm:w-auto">
|
||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Settings2 className="h-4 w-4" />}
|
||
{saving ? "儲存中…" : "儲存設定"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|