feat(content): Phase 8 content generation (Faz A+B) for Sase.tr
Hybrid content automation pilot: generation + review + drafts live in the panel (reusing the insight pipeline's DeepSeek client, prompt_templates versioning, cost_ledger and budget_settings); publishing/distribution will go through n8n (Faz C, not built). Channels: blog, LinkedIn, X, Instagram. Topic sourcing is automatic (LLM-generated ideas). Approval model: drafts sit in the panel for manual review/edit/publish. Faz A (worker): - ContentTopic / ContentDraft Prisma models (content_topics, content_drafts) - content-prompts.ts: 5 seed prompts (topic ideas[pro] + blog[pro] + linkedin/x/instagram[flash]), Turkish B2B automotive tone, per-channel JSON schemas - content-budget.ts: separate budget envelope (sums only content_* spend) - content-topics job (auto idea gen, backlog-capped, title dedupe) + content-generate job (queued topic -> one draft per channel) - content-pipeline scheduler (separate BullMQ queue, topics@*/8h, generate@*/10min), wired into index.ts; seeded via seed-runtime - content budget settings (caps + content_paused kill switch); seed default content_paused=true for a safe first deploy Faz B (web): - /content (queue + auto/manual triggers + manual topic form), /content/t/[id] (per-channel draft cards: preview, JSON edits, approve/reject), /content/costs (content-only spend) - server actions (audit-logged), manual trigger API routes, contentQueue(), nav + Cmd+K entries - content caps surfaced on /insights/settings/budgets + whitelisted Both packages typecheck. Schema applies on deploy (web start runs prisma db push). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -364,6 +364,71 @@ model EvalRun {
|
||||
@@map("eval_runs")
|
||||
}
|
||||
|
||||
// ---------- Phase 8a: Content generation ----------
|
||||
|
||||
// A topic/brief for content generation. Either auto-generated by the
|
||||
// content-topics job (LLM idea generation) or entered manually. One topic
|
||||
// fans out into one ContentDraft per selected channel.
|
||||
model ContentTopic {
|
||||
id String @id @default(cuid())
|
||||
projectKey String
|
||||
title String
|
||||
brief String @db.Text
|
||||
angle String? @db.Text
|
||||
channels String[] // ["blog","linkedin","x","instagram"]
|
||||
keywords String[]
|
||||
/// queued | generating | drafted | archived
|
||||
status String @default("queued")
|
||||
/// auto | manual
|
||||
source String @default("auto")
|
||||
fingerprint String? // dedupe near-identical auto topics
|
||||
sourcePromptTag String?
|
||||
sourcePromptVersion Int?
|
||||
sourceModel String?
|
||||
sourceCostUsd Float @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
drafts ContentDraft[]
|
||||
|
||||
@@index([projectKey, status])
|
||||
@@index([fingerprint])
|
||||
@@map("content_topics")
|
||||
}
|
||||
|
||||
// One generated piece of content for a single channel. Lives as a draft in
|
||||
// the panel; founder reviews/edits, then publishes via the n8n webhook
|
||||
// (Phase 8c). bodyJson holds the channel-specific generated structure;
|
||||
// founderEdits holds any human overrides applied before publish.
|
||||
model ContentDraft {
|
||||
id String @id @default(cuid())
|
||||
topicId String
|
||||
projectKey String
|
||||
/// blog | linkedin | x | instagram
|
||||
channel String
|
||||
/// draft | approved | publishing | published | failed | rejected
|
||||
status String @default("draft")
|
||||
bodyJson Json
|
||||
founderEdits Json?
|
||||
publishedUrl String?
|
||||
n8nExecutionId String?
|
||||
publishError String?
|
||||
sourcePromptTag String
|
||||
sourcePromptVersion Int
|
||||
sourceModel String
|
||||
sourceCostUsd Float @default(0)
|
||||
publishedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
topic ContentTopic @relation(fields: [topicId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([projectKey, status])
|
||||
@@index([topicId])
|
||||
@@index([channel, status])
|
||||
@@map("content_drafts")
|
||||
}
|
||||
|
||||
// ---------- Phase 7a: Sase user-management ----------
|
||||
|
||||
// Founder-only notes pinned to a Sase user. Stored panel-side (KVKK minimize:
|
||||
|
||||
27
apps/web/src/app/api/content/generate/route.ts
Normal file
27
apps/web/src/app/api/content/generate/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { contentQueue } from "@/lib/queue";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Manually trigger one content-generate run (queued topics → channel drafts).
|
||||
// Also scheduled every 10min; this is for on-demand kicks after queueing topics.
|
||||
export async function POST() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
|
||||
|
||||
const job = await contentQueue().add(
|
||||
"content-generate",
|
||||
{},
|
||||
{ removeOnComplete: 50, removeOnFail: 25 },
|
||||
);
|
||||
await writeAudit({
|
||||
projectKey: "sase",
|
||||
endpoint: "/api/content/generate",
|
||||
method: "POST",
|
||||
responseStatus: 200,
|
||||
});
|
||||
return NextResponse.json({ ok: true, jobId: job.id });
|
||||
}
|
||||
27
apps/web/src/app/api/content/topics/generate/route.ts
Normal file
27
apps/web/src/app/api/content/topics/generate/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { contentQueue } from "@/lib/queue";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Manually trigger one content-topics run (auto topic-idea generation).
|
||||
// The job itself is also scheduled (every 8h) — this is for on-demand kicks.
|
||||
export async function POST() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
|
||||
|
||||
const job = await contentQueue().add(
|
||||
"content-topics",
|
||||
{},
|
||||
{ removeOnComplete: 30, removeOnFail: 15 },
|
||||
);
|
||||
await writeAudit({
|
||||
projectKey: "sase",
|
||||
endpoint: "/api/content/topics/generate",
|
||||
method: "POST",
|
||||
responseStatus: 200,
|
||||
});
|
||||
return NextResponse.json({ ok: true, jobId: job.id });
|
||||
}
|
||||
151
apps/web/src/app/content/_actions.ts
Normal file
151
apps/web/src/app/content/_actions.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import { contentQueue } from "@/lib/queue";
|
||||
|
||||
const PROJECT_KEY = "sase";
|
||||
const VALID_CHANNELS = ["blog", "linkedin", "x", "instagram"];
|
||||
// Draft lifecycle reachable from the UI. publishing/published/failed are set
|
||||
// by the publish action + n8n callback (Phase 8c), not directly here.
|
||||
const DRAFT_STATUS = new Set(["draft", "approved", "rejected"]);
|
||||
|
||||
async function requireSession() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) throw new Error("unauthenticated");
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function createTopic(input: {
|
||||
title: string;
|
||||
brief: string;
|
||||
angle?: string;
|
||||
channels: string[];
|
||||
keywords: string[];
|
||||
}) {
|
||||
await requireSession();
|
||||
const title = input.title.trim().slice(0, 250);
|
||||
if (!title) throw new Error("title required");
|
||||
const channels = input.channels.filter((c) => VALID_CHANNELS.includes(c));
|
||||
if (channels.length === 0) throw new Error("pick at least one channel");
|
||||
|
||||
const topic = await prisma.contentTopic.create({
|
||||
data: {
|
||||
projectKey: PROJECT_KEY,
|
||||
title,
|
||||
brief: input.brief.trim().slice(0, 2000),
|
||||
angle: input.angle?.trim().slice(0, 1000) || null,
|
||||
channels,
|
||||
keywords: input.keywords.map((k) => k.trim()).filter(Boolean).slice(0, 12),
|
||||
status: "queued",
|
||||
source: "manual",
|
||||
},
|
||||
});
|
||||
await writeAudit({
|
||||
projectKey: PROJECT_KEY,
|
||||
endpoint: "/content/topics",
|
||||
method: "POST",
|
||||
requestPayload: { title, channels },
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath("/content");
|
||||
return topic.id;
|
||||
}
|
||||
|
||||
export async function archiveTopic(topicId: string) {
|
||||
await requireSession();
|
||||
await prisma.contentTopic.update({ where: { id: topicId }, data: { status: "archived" } });
|
||||
await writeAudit({
|
||||
projectKey: PROJECT_KEY,
|
||||
endpoint: `/content/topics/${topicId}/archive`,
|
||||
method: "POST",
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath("/content");
|
||||
revalidatePath(`/content/t/${topicId}`);
|
||||
}
|
||||
|
||||
export async function requeueTopic(topicId: string) {
|
||||
await requireSession();
|
||||
await prisma.contentTopic.update({ where: { id: topicId }, data: { status: "queued" } });
|
||||
await writeAudit({
|
||||
projectKey: PROJECT_KEY,
|
||||
endpoint: `/content/topics/${topicId}/requeue`,
|
||||
method: "POST",
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath("/content");
|
||||
revalidatePath(`/content/t/${topicId}`);
|
||||
}
|
||||
|
||||
export async function enqueueTopicGeneration() {
|
||||
await requireSession();
|
||||
const job = await contentQueue().add("content-topics", {}, { removeOnComplete: 30, removeOnFail: 15 });
|
||||
await writeAudit({
|
||||
projectKey: PROJECT_KEY,
|
||||
endpoint: "/content/topics/generate",
|
||||
method: "POST",
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath("/content");
|
||||
return job.id;
|
||||
}
|
||||
|
||||
export async function enqueueContentGeneration() {
|
||||
await requireSession();
|
||||
const job = await contentQueue().add("content-generate", {}, { removeOnComplete: 50, removeOnFail: 25 });
|
||||
await writeAudit({
|
||||
projectKey: PROJECT_KEY,
|
||||
endpoint: "/content/generate",
|
||||
method: "POST",
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath("/content");
|
||||
return job.id;
|
||||
}
|
||||
|
||||
export async function saveDraftEdits(draftId: string, editsJson: string) {
|
||||
await requireSession();
|
||||
let parsed: unknown = null;
|
||||
const trimmed = editsJson.trim();
|
||||
if (trimmed) {
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch (e) {
|
||||
throw new Error(`invalid JSON: ${(e as Error).message}`);
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error("edits must be a JSON object");
|
||||
}
|
||||
}
|
||||
const draft = await prisma.contentDraft.update({
|
||||
where: { id: draftId },
|
||||
data: { founderEdits: parsed === null ? undefined : (parsed as object) },
|
||||
});
|
||||
await writeAudit({
|
||||
projectKey: PROJECT_KEY,
|
||||
endpoint: `/content/drafts/${draftId}/edits`,
|
||||
method: "POST",
|
||||
requestPayload: { length: trimmed.length },
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath(`/content/t/${draft.topicId}`);
|
||||
}
|
||||
|
||||
export async function setDraftStatus(draftId: string, status: string) {
|
||||
await requireSession();
|
||||
if (!DRAFT_STATUS.has(status)) throw new Error(`bad status: ${status}`);
|
||||
const draft = await prisma.contentDraft.update({ where: { id: draftId }, data: { status } });
|
||||
await writeAudit({
|
||||
projectKey: PROJECT_KEY,
|
||||
endpoint: `/content/drafts/${draftId}/status`,
|
||||
method: "POST",
|
||||
requestPayload: { status },
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath(`/content/t/${draft.topicId}`);
|
||||
revalidatePath("/content");
|
||||
}
|
||||
134
apps/web/src/app/content/_controls.tsx
Normal file
134
apps/web/src/app/content/_controls.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
createTopic,
|
||||
enqueueTopicGeneration,
|
||||
enqueueContentGeneration,
|
||||
} from "./_actions";
|
||||
|
||||
const CHANNELS = [
|
||||
{ value: "blog", label: "Blog" },
|
||||
{ value: "linkedin", label: "LinkedIn" },
|
||||
{ value: "x", label: "X" },
|
||||
{ value: "instagram", label: "Instagram" },
|
||||
];
|
||||
|
||||
export function ContentControls({ queuedCount }: { queuedCount: number }) {
|
||||
const router = useRouter();
|
||||
const [pending, start] = useTransition();
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
// manual topic form state
|
||||
const [title, setTitle] = useState("");
|
||||
const [brief, setBrief] = useState("");
|
||||
const [angle, setAngle] = useState("");
|
||||
const [keywords, setKeywords] = useState("");
|
||||
const [channels, setChannels] = useState<string[]>(["blog", "linkedin", "x", "instagram"]);
|
||||
|
||||
const fire = (fn: () => Promise<unknown>, label: string) =>
|
||||
start(async () => {
|
||||
try {
|
||||
await fn();
|
||||
setFlash(label);
|
||||
setTimeout(() => setFlash(null), 2500);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setFlash(`hata: ${(e as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
const toggleChannel = (c: string) =>
|
||||
setChannels((prev) => (prev.includes(c) ? prev.filter((x) => x !== c) : [...prev, c]));
|
||||
|
||||
const submitTopic = () =>
|
||||
fire(async () => {
|
||||
await createTopic({
|
||||
title,
|
||||
brief,
|
||||
angle,
|
||||
channels,
|
||||
keywords: keywords.split(",").map((k) => k.trim()).filter(Boolean),
|
||||
});
|
||||
setTitle("");
|
||||
setBrief("");
|
||||
setAngle("");
|
||||
setKeywords("");
|
||||
setShowForm(false);
|
||||
}, "konu eklendi");
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
onClick={() => fire(() => enqueueTopicGeneration(), "konu üretimi kuyruğa alındı")}
|
||||
>
|
||||
Otomatik konu üret
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
onClick={() => fire(() => enqueueContentGeneration(), "üretim kuyruğa alındı")}
|
||||
>
|
||||
Taslakları üret ({queuedCount} kuyrukta)
|
||||
</Button>
|
||||
<Button size="sm" variant={showForm ? "default" : "outline"} onClick={() => setShowForm((v) => !v)}>
|
||||
{showForm ? "Formu kapat" : "Elle konu ekle"}
|
||||
</Button>
|
||||
{flash && <span className="text-xs text-muted-foreground">{flash}</span>}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<input
|
||||
className="w-full rounded border bg-background px-2 py-1 text-sm"
|
||||
placeholder="Başlık"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className="w-full rounded border bg-background p-2 text-sm"
|
||||
rows={2}
|
||||
placeholder="Brief — içerik ne anlatacak?"
|
||||
value={brief}
|
||||
onChange={(e) => setBrief(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="w-full rounded border bg-background px-2 py-1 text-sm"
|
||||
placeholder="Açı (opsiyonel)"
|
||||
value={angle}
|
||||
onChange={(e) => setAngle(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="w-full rounded border bg-background px-2 py-1 text-sm"
|
||||
placeholder="Anahtar kelimeler (virgülle)"
|
||||
value={keywords}
|
||||
onChange={(e) => setKeywords(e.target.value)}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{CHANNELS.map((c) => (
|
||||
<label key={c.value} className="flex items-center gap-1 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={channels.includes(c.value)}
|
||||
onChange={() => toggleChannel(c.value)}
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
<Button size="sm" disabled={pending || !title.trim()} onClick={submitTopic}>
|
||||
Ekle
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
apps/web/src/app/content/costs/page.tsx
Normal file
174
apps/web/src/app/content/costs/page.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CONTENT_FILTER = { promptTag: { startsWith: "content_" } };
|
||||
|
||||
function startOfDayUtc(d: Date): Date {
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||
}
|
||||
function startOfMonthUtc(d: Date): Date {
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
|
||||
}
|
||||
|
||||
export default async function ContentCostDashboard() {
|
||||
const now = new Date();
|
||||
|
||||
const [todayAgg, monthAgg, byPrompt, recentErrors, budgetSettings, draftCount, topicCount] =
|
||||
await Promise.all([
|
||||
prisma.costLedger.aggregate({
|
||||
where: { ...CONTENT_FILTER, createdAt: { gte: startOfDayUtc(now) } },
|
||||
_sum: { costTotalUsd: true },
|
||||
_count: true,
|
||||
}),
|
||||
prisma.costLedger.aggregate({
|
||||
where: { ...CONTENT_FILTER, createdAt: { gte: startOfMonthUtc(now) } },
|
||||
_sum: { costTotalUsd: true },
|
||||
_count: true,
|
||||
}),
|
||||
prisma.costLedger.groupBy({
|
||||
by: ["promptTag"],
|
||||
where: { ...CONTENT_FILTER, createdAt: { gte: startOfMonthUtc(now) } },
|
||||
_sum: { costTotalUsd: true },
|
||||
_count: true,
|
||||
}),
|
||||
prisma.costLedger.findMany({
|
||||
where: { ...CONTENT_FILTER, errorCode: { not: null } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
}),
|
||||
prisma.budgetSetting.findMany({ where: { projectKey: null } }),
|
||||
prisma.contentDraft.count({ where: { projectKey: "sase" } }),
|
||||
prisma.contentTopic.count({ where: { projectKey: "sase" } }),
|
||||
]);
|
||||
|
||||
const limits: Record<string, number> = {};
|
||||
for (const b of budgetSettings) {
|
||||
if (typeof b.settingValue === "number") limits[b.settingKey] = b.settingValue;
|
||||
}
|
||||
const monthlyCap = limits.content_monthly_hard_cap_usd ?? 15;
|
||||
const dailyHardCap = limits.content_daily_hard_cap_usd ?? 2;
|
||||
|
||||
const today = Number(todayAgg._sum.costTotalUsd ?? 0);
|
||||
const month = Number(monthAgg._sum.costTotalUsd ?? 0);
|
||||
const monthCalls = Number(monthAgg._count ?? 0);
|
||||
const avgPerCall = monthCalls > 0 ? month / monthCalls : 0;
|
||||
const todayPct = Math.min(100, (today / dailyHardCap) * 100);
|
||||
const monthPct = Math.min(100, (month / monthlyCap) * 100);
|
||||
|
||||
return (
|
||||
<PanelShell title="Content · maliyet">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
İçerik üretimi DeepSeek harcaması (insight bütçesinden ayrı).{" "}
|
||||
<a href="/content" className="underline">Kuyruk</a> ·{" "}
|
||||
<a href="/insights/settings/budgets" className="underline">Bütçe ayarları</a>
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Bugün</CardDescription>
|
||||
<CardTitle className="text-2xl">${today.toFixed(4)}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-muted-foreground">
|
||||
{todayPct.toFixed(0)}% / günlük sert sınır ${dailyHardCap}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Bu ay</CardDescription>
|
||||
<CardTitle className="text-2xl">${month.toFixed(2)}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-muted-foreground">
|
||||
{monthPct.toFixed(0)}% / aylık sınır ${monthlyCap}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Çağrı başı ort. (ay)</CardDescription>
|
||||
<CardTitle className="text-2xl">${avgPerCall.toFixed(4)}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-muted-foreground">{monthCalls} çağrı</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Üretilen</CardDescription>
|
||||
<CardTitle className="text-2xl">{draftCount}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-muted-foreground">{topicCount} konu · taslak</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-2 text-sm font-medium text-muted-foreground">Prompt başına (bu ay)</h2>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Prompt</TableHead>
|
||||
<TableHead className="w-[120px]">Çağrı</TableHead>
|
||||
<TableHead className="w-[120px]">Maliyet</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{byPrompt.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center text-xs text-muted-foreground">
|
||||
Henüz içerik harcaması yok.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
byPrompt.map((p) => (
|
||||
<TableRow key={p.promptTag ?? "—"}>
|
||||
<TableCell className="text-xs">{p.promptTag ?? "—"}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{p._count}</TableCell>
|
||||
<TableCell className="font-mono text-xs">${Number(p._sum.costTotalUsd ?? 0).toFixed(4)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{recentErrors.length > 0 && (
|
||||
<>
|
||||
<h2 className="mt-2 text-sm font-medium text-muted-foreground">Son hatalar</h2>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Zaman</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead>Prompt</TableHead>
|
||||
<TableHead>Hata</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recentErrors.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{r.createdAt.toISOString().slice(0, 19).replace("T", " ")}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{r.model}</TableCell>
|
||||
<TableCell className="text-xs">{r.promptTag ?? "—"}</TableCell>
|
||||
<TableCell><Badge variant="destructive">{r.errorCode}</Badge></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
113
apps/web/src/app/content/page.tsx
Normal file
113
apps/web/src/app/content/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ContentControls } from "./_controls";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PROJECT_KEY = "sase";
|
||||
|
||||
function statusVariant(status: string): "default" | "secondary" | "outline" | "destructive" {
|
||||
switch (status) {
|
||||
case "drafted":
|
||||
return "default";
|
||||
case "generating":
|
||||
return "secondary";
|
||||
case "archived":
|
||||
return "destructive";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ContentInbox() {
|
||||
const [topics, paused, queuedCount] = await Promise.all([
|
||||
prisma.contentTopic.findMany({
|
||||
where: { projectKey: PROJECT_KEY, status: { not: "archived" } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
include: { drafts: { select: { status: true, channel: true } } },
|
||||
}),
|
||||
prisma.budgetSetting.findFirst({ where: { projectKey: null, settingKey: "content_paused" } }),
|
||||
prisma.contentTopic.count({
|
||||
where: { projectKey: PROJECT_KEY, status: { in: ["queued", "generating"] } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const isPaused = paused?.settingValue === true;
|
||||
|
||||
return (
|
||||
<PanelShell title="Content · konu kuyruğu">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sase.tr için otomatik içerik üretimi. Konular LLM ile üretilir, kanal taslakları panelde birikir, sen
|
||||
incele/düzenle/onayla.{" "}
|
||||
<a href="/content/costs" className="underline">Maliyet</a>
|
||||
{isPaused && (
|
||||
<>
|
||||
{" · "}
|
||||
<Badge variant="destructive">content_paused</Badge>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<ContentControls queuedCount={queuedCount} />
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Konu</TableHead>
|
||||
<TableHead className="w-[110px]">Durum</TableHead>
|
||||
<TableHead className="w-[90px]">Kaynak</TableHead>
|
||||
<TableHead className="w-[160px]">Kanallar</TableHead>
|
||||
<TableHead className="w-[90px]">Taslak</TableHead>
|
||||
<TableHead className="w-[110px]">Oluşturma</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{topics.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-xs text-muted-foreground">
|
||||
Henüz konu yok. Yukarıdan otomatik üret ya da elle ekle.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
topics.map((t) => {
|
||||
const approved = t.drafts.filter((d) => d.status === "approved").length;
|
||||
return (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell>
|
||||
<a href={`/content/t/${t.id}`} className="font-medium underline-offset-2 hover:underline">
|
||||
{t.title}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(t.status)}>{t.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{t.source}</TableCell>
|
||||
<TableCell className="text-xs">{(t.channels as string[]).join(", ")}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{t.drafts.length}
|
||||
{approved > 0 && <span className="text-muted-foreground"> ({approved}✓)</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{t.createdAt.toISOString().slice(0, 10)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
191
apps/web/src/app/content/t/[id]/_draft-card.tsx
Normal file
191
apps/web/src/app/content/t/[id]/_draft-card.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { saveDraftEdits, setDraftStatus } from "../../_actions";
|
||||
|
||||
type Json = unknown;
|
||||
|
||||
type Props = {
|
||||
draftId: string;
|
||||
channel: string;
|
||||
status: string;
|
||||
bodyJson: Json;
|
||||
founderEdits: Json;
|
||||
model: string | null;
|
||||
costUsd: number;
|
||||
publishedUrl: string | null;
|
||||
publishError: string | null;
|
||||
};
|
||||
|
||||
function asRecord(v: Json): Record<string, unknown> {
|
||||
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function str(v: unknown): string {
|
||||
return typeof v === "string" ? v : v == null ? "" : String(v);
|
||||
}
|
||||
function arr(v: unknown): string[] {
|
||||
return Array.isArray(v) ? v.map(str) : [];
|
||||
}
|
||||
|
||||
function ChannelPreview({ channel, content }: { channel: string; content: Record<string, unknown> }) {
|
||||
if (channel === "blog") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-base font-semibold">{str(content.title)}</div>
|
||||
<div className="text-xs text-muted-foreground">/{str(content.slug)}</div>
|
||||
<div className="text-xs italic text-muted-foreground">{str(content.meta_description)}</div>
|
||||
<pre className="whitespace-pre-wrap rounded bg-muted/40 p-2 text-sm">{str(content.body_markdown)}</pre>
|
||||
{content.cta != null && <div className="text-sm">CTA: {str(content.cta)}</div>}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{arr(content.tags).map((t) => (
|
||||
<Badge key={t} variant="outline" className="text-xs">{t}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (channel === "x") {
|
||||
const tweets = arr(content.tweets);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{tweets.map((t, i) => (
|
||||
<div key={i} className="rounded border p-2 text-sm">
|
||||
<span className="mr-2 text-xs text-muted-foreground">{i + 1}/{tweets.length}</span>
|
||||
{t}
|
||||
<span className="ml-2 text-xs text-muted-foreground">({t.length})</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{arr(content.hashtags).map((h) => (
|
||||
<Badge key={h} variant="outline" className="text-xs">{h}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// linkedin / instagram — body or caption + hashtags
|
||||
const text = channel === "instagram" ? str(content.caption) : str(content.body);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<pre className="whitespace-pre-wrap rounded bg-muted/40 p-2 text-sm">{text}</pre>
|
||||
{content.cta != null && <div className="text-sm">CTA: {str(content.cta)}</div>}
|
||||
{content.image_prompt != null && (
|
||||
<div className="text-xs text-muted-foreground">image_prompt: {str(content.image_prompt)}</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{arr(content.hashtags).map((h) => (
|
||||
<Badge key={h} variant="outline" className="text-xs">{h}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_VARIANT: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
|
||||
approved: "default",
|
||||
draft: "outline",
|
||||
rejected: "destructive",
|
||||
published: "default",
|
||||
publishing: "secondary",
|
||||
failed: "destructive",
|
||||
};
|
||||
|
||||
export function DraftCard(props: Props) {
|
||||
const router = useRouter();
|
||||
const [pending, start] = useTransition();
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editsText, setEditsText] = useState(
|
||||
props.founderEdits ? JSON.stringify(props.founderEdits, null, 2) : "",
|
||||
);
|
||||
|
||||
const base = asRecord(props.bodyJson);
|
||||
const edits = asRecord(props.founderEdits);
|
||||
const effective = { ...base, ...edits };
|
||||
const locked = ["published", "publishing"].includes(props.status);
|
||||
|
||||
const fire = (fn: () => Promise<unknown>, label: string) =>
|
||||
start(async () => {
|
||||
try {
|
||||
await fn();
|
||||
setFlash(label);
|
||||
setTimeout(() => setFlash(null), 2500);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setFlash(`hata: ${(e as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-sm font-medium uppercase">{props.channel}</span>
|
||||
<Badge variant={STATUS_VARIANT[props.status] ?? "outline"}>{props.status}</Badge>
|
||||
{Object.keys(edits).length > 0 && <Badge variant="secondary" className="text-xs">düzenlendi</Badge>}
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{props.model ?? "—"} · ${props.costUsd.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{props.publishedUrl && (
|
||||
<div className="text-xs">
|
||||
Yayınlandı: <a href={props.publishedUrl} className="underline" target="_blank" rel="noreferrer">{props.publishedUrl}</a>
|
||||
</div>
|
||||
)}
|
||||
{props.publishError && <div className="text-xs text-destructive">Yayın hatası: {props.publishError}</div>}
|
||||
|
||||
<ChannelPreview channel={props.channel} content={effective} />
|
||||
|
||||
{editing && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
founder edits (JSON object — sadece değiştirmek istediğin alanları yaz, üzerine biner)
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full rounded border bg-background p-2 font-mono text-xs"
|
||||
rows={8}
|
||||
value={editsText}
|
||||
onChange={(e) => setEditsText(e.target.value)}
|
||||
placeholder={'{\n "title": "..."\n}'}
|
||||
/>
|
||||
<Button size="sm" variant="outline" disabled={pending} onClick={() => fire(() => saveDraftEdits(props.draftId, editsText), "düzenleme kaydedildi")}>
|
||||
Düzenlemeyi kaydet
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!locked && (
|
||||
<div className="flex flex-wrap items-center gap-2 border-t pt-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing((v) => !v)}>
|
||||
{editing ? "Düzenlemeyi gizle" : "Düzenle"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={pending || props.status === "approved"}
|
||||
onClick={() => fire(() => setDraftStatus(props.draftId, "approved"), "onaylandı")}
|
||||
>
|
||||
Onayla
|
||||
</Button>
|
||||
{props.status !== "draft" && (
|
||||
<Button size="sm" variant="outline" disabled={pending} onClick={() => fire(() => setDraftStatus(props.draftId, "draft"), "taslağa alındı")}>
|
||||
Taslağa al
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={pending || props.status === "rejected"}
|
||||
onClick={() => fire(() => setDraftStatus(props.draftId, "rejected"), "reddedildi")}
|
||||
>
|
||||
Reddet
|
||||
</Button>
|
||||
{flash && <span className="text-xs text-muted-foreground">{flash}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
apps/web/src/app/content/t/[id]/_topic-actions.tsx
Normal file
43
apps/web/src/app/content/t/[id]/_topic-actions.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { archiveTopic, requeueTopic, enqueueContentGeneration } from "../../_actions";
|
||||
|
||||
export function TopicActions({ topicId, status }: { topicId: string; status: string }) {
|
||||
const router = useRouter();
|
||||
const [pending, start] = useTransition();
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
|
||||
const fire = (fn: () => Promise<unknown>, label: string) =>
|
||||
start(async () => {
|
||||
try {
|
||||
await fn();
|
||||
setFlash(label);
|
||||
setTimeout(() => setFlash(null), 2500);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setFlash(`hata: ${(e as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
{status !== "queued" && (
|
||||
<Button size="sm" variant="outline" disabled={pending} onClick={() => fire(() => requeueTopic(topicId), "kuyruğa alındı")}>
|
||||
Yeniden kuyruğa al
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" disabled={pending} onClick={() => fire(() => enqueueContentGeneration(), "üretim kuyruğa alındı")}>
|
||||
Taslakları üret
|
||||
</Button>
|
||||
{status !== "archived" && (
|
||||
<Button size="sm" variant="destructive" disabled={pending} onClick={() => fire(() => archiveTopic(topicId), "arşivlendi")}>
|
||||
Arşivle
|
||||
</Button>
|
||||
)}
|
||||
{flash && <span className="text-xs text-muted-foreground">{flash}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
apps/web/src/app/content/t/[id]/page.tsx
Normal file
76
apps/web/src/app/content/t/[id]/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DraftCard } from "./_draft-card";
|
||||
import { TopicActions } from "./_topic-actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CHANNEL_ORDER = ["blog", "linkedin", "x", "instagram"];
|
||||
|
||||
export default async function TopicDetail({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const topic = await prisma.contentTopic.findUnique({
|
||||
where: { id },
|
||||
include: { drafts: true },
|
||||
});
|
||||
if (!topic) notFound();
|
||||
|
||||
const drafts = [...topic.drafts].sort(
|
||||
(a, b) => CHANNEL_ORDER.indexOf(a.channel) - CHANNEL_ORDER.indexOf(b.channel),
|
||||
);
|
||||
const totalCost = topic.drafts.reduce((s, d) => s + d.sourceCostUsd, topic.sourceCostUsd);
|
||||
|
||||
return (
|
||||
<PanelShell title="Content · konu">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<a href="/content" className="underline">← Kuyruk</a>
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-lg font-semibold">{topic.title}</h1>
|
||||
<Badge variant="outline">{topic.status}</Badge>
|
||||
<Badge variant="secondary">{topic.source}</Badge>
|
||||
</div>
|
||||
{topic.brief && <p className="text-sm">{topic.brief}</p>}
|
||||
{topic.angle && <p className="text-xs text-muted-foreground">Açı: {topic.angle}</p>}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(topic.keywords as string[]).map((k) => (
|
||||
<Badge key={k} variant="outline" className="text-xs">{k}</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Kanallar: {(topic.channels as string[]).join(", ")} · Maliyet: ${totalCost.toFixed(4)} ·{" "}
|
||||
{topic.sourceModel ?? "—"}
|
||||
</div>
|
||||
<TopicActions topicId={topic.id} status={topic.status} />
|
||||
</div>
|
||||
|
||||
{drafts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Henüz taslak üretilmemiş. Konu kuyruktaysa <span className="font-mono">content-generate</span> job'u
|
||||
(≤10dk) ya da kuyruk sayfasındaki “Taslakları üret” bunu işler.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{drafts.map((d) => (
|
||||
<DraftCard
|
||||
key={d.id}
|
||||
draftId={d.id}
|
||||
channel={d.channel}
|
||||
status={d.status}
|
||||
bodyJson={d.bodyJson}
|
||||
founderEdits={d.founderEdits}
|
||||
model={d.sourceModel}
|
||||
costUsd={d.sourceCostUsd}
|
||||
publishedUrl={d.publishedUrl}
|
||||
publishError={d.publishError}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
@@ -318,6 +318,12 @@ export async function updateBudgetSetting(key: string, value: number | boolean)
|
||||
"min_score_for_analysis",
|
||||
"cache_ttl_hours",
|
||||
"analysis_paused",
|
||||
// Content generation (separate envelope) — see lib/content-budget.ts
|
||||
"content_monthly_hard_cap_usd",
|
||||
"content_daily_soft_cap_usd",
|
||||
"content_daily_hard_cap_usd",
|
||||
"content_per_call_max_usd",
|
||||
"content_paused",
|
||||
]);
|
||||
if (!allowed.has(key)) throw new Error("bad setting key");
|
||||
const existing = await prisma.budgetSetting.findFirst({
|
||||
@@ -342,4 +348,8 @@ export async function updateBudgetSetting(key: string, value: number | boolean)
|
||||
revalidatePath("/insights/settings/budgets");
|
||||
revalidatePath("/insights/costs");
|
||||
revalidatePath("/insights");
|
||||
if (key.startsWith("content_")) {
|
||||
revalidatePath("/content/costs");
|
||||
revalidatePath("/content");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,41 @@ const SCHEMA: Array<{ key: string; label: string; unit?: string; help?: string;
|
||||
help: "Kill switch — no LLM calls until cleared. Pipeline still ingests/tags/compresses.",
|
||||
default: false,
|
||||
},
|
||||
// ---- Content generation (separate envelope from insight analysis) ----
|
||||
{
|
||||
key: "content_monthly_hard_cap_usd",
|
||||
label: "Content · monthly hard cap",
|
||||
unit: "USD/month",
|
||||
help: "Content generation halts when its month-to-date spend reaches this.",
|
||||
default: 15,
|
||||
},
|
||||
{
|
||||
key: "content_daily_soft_cap_usd",
|
||||
label: "Content · daily soft cap",
|
||||
unit: "USD/day",
|
||||
help: "Above this, content pro tier downgrades to flash for the rest of today.",
|
||||
default: 1,
|
||||
},
|
||||
{
|
||||
key: "content_daily_hard_cap_usd",
|
||||
label: "Content · daily hard cap",
|
||||
unit: "USD/day",
|
||||
help: "Content generation paused above this until UTC midnight.",
|
||||
default: 2,
|
||||
},
|
||||
{
|
||||
key: "content_per_call_max_usd",
|
||||
label: "Content · per-call max",
|
||||
unit: "USD",
|
||||
help: "Single content LLM call ceiling. Currently warn-only.",
|
||||
default: 0.3,
|
||||
},
|
||||
{
|
||||
key: "content_paused",
|
||||
label: "Pause content generation",
|
||||
help: "Kill switch for content topics + drafts. Independent of analysis_paused.",
|
||||
default: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default async function BudgetsSettingsPage() {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FolderIcon,
|
||||
LayoutDashboardIcon,
|
||||
LightbulbIcon,
|
||||
PenLineIcon,
|
||||
ScrollTextIcon,
|
||||
Settings2Icon,
|
||||
TerminalIcon,
|
||||
@@ -30,6 +31,7 @@ const navMain = [
|
||||
{ title: "Projects", url: "/projects", icon: <FolderIcon /> },
|
||||
{ title: "Operations", url: "/operations", icon: <TerminalIcon /> },
|
||||
{ title: "Insights", url: "/insights", icon: <LightbulbIcon /> },
|
||||
{ title: "Content", url: "/content", icon: <PenLineIcon /> },
|
||||
{ title: "Events", url: "/events", icon: <ActivityIcon /> },
|
||||
{ title: "Audit", url: "/audit", icon: <ScrollTextIcon /> },
|
||||
];
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
LayoutDashboardIcon,
|
||||
LightbulbIcon,
|
||||
LogOutIcon,
|
||||
PenLineIcon,
|
||||
ScrollTextIcon,
|
||||
Settings2Icon,
|
||||
SlidersHorizontalIcon,
|
||||
@@ -94,6 +95,17 @@ export function CommandPalette({ projects }: { projects: ProjectLite[] }) {
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="Content">
|
||||
<CommandItem keywords={["content","icerik","konu","kuyruk","queue"]} onSelect={() => go("/content")}>
|
||||
<PenLineIcon /> İçerik kuyruğu
|
||||
</CommandItem>
|
||||
<CommandItem keywords={["content","cost","maliyet","spend"]} onSelect={() => go("/content/costs")}>
|
||||
<CoinsIcon /> İçerik maliyeti
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="Projects">
|
||||
{projects.map((p) => (
|
||||
<CommandItem
|
||||
|
||||
@@ -3,6 +3,7 @@ import IORedis from "ioredis";
|
||||
|
||||
const url = process.env.REDIS_URL;
|
||||
let _queue: Queue | null = null;
|
||||
let _contentQueue: Queue | null = null;
|
||||
|
||||
export function pipelineQueue(): Queue {
|
||||
if (_queue) return _queue;
|
||||
@@ -11,3 +12,11 @@ export function pipelineQueue(): Queue {
|
||||
_queue = new Queue("insight-pipeline", { connection });
|
||||
return _queue;
|
||||
}
|
||||
|
||||
export function contentQueue(): Queue {
|
||||
if (_contentQueue) return _contentQueue;
|
||||
if (!url) throw new Error("REDIS_URL not set");
|
||||
const connection = new IORedis(url, { maxRetriesPerRequest: null });
|
||||
_contentQueue = new Queue("content-pipeline", { connection });
|
||||
return _contentQueue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user