diff --git a/app/apps/[id]/page.tsx b/app/apps/[id]/page.tsx index da4e3ef..e911420 100644 --- a/app/apps/[id]/page.tsx +++ b/app/apps/[id]/page.tsx @@ -2,7 +2,7 @@ import { use, useState, useEffect } from "react"; import { useTranslations } from "next-intl"; -import { triggerLabel } from "@/lib/appTrigger"; +import { presentationModeLabel } from "@/lib/appTrigger"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useAppSkills } from "@/hooks/useAppSkills"; @@ -336,9 +336,9 @@ export default function AppDetailPage({ params }: { params: Promise<{ id: string )} - {/* 副标题:说明 · 触发方式 · 平台。描述可能为空,空时不留下孤零零的分隔点 */} + {/* 副标题:说明 · 展示方式 · 平台。描述可能为空,空时不留下孤零零的分隔点 */}

- {[appInfo.description, triggerLabel(appInfo.app_type, t), appInfo.platform] + {[appInfo.description, presentationModeLabel(appInfo.app_type, t), appInfo.platform] .filter(Boolean) .join(" · ")}

diff --git a/app/apps/components/subscription-agent/FeedItemsDialog.tsx b/app/apps/components/subscription-agent/FeedItemsDialog.tsx deleted file mode 100644 index 3803342..0000000 --- a/app/apps/components/subscription-agent/FeedItemsDialog.tsx +++ /dev/null @@ -1,259 +0,0 @@ -"use client"; - -import { useState, useEffect, useCallback } from "react"; -import { useTranslations } from "next-intl"; -import axios from "@/lib/axios"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Loader2, RefreshCw, ExternalLink, Download } from "lucide-react"; -import { toast } from "sonner"; -import type { Feed, FeedItem } from "@/types/subscription-agent"; - -interface FeedItemsDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - feedIds: string[]; - appName?: string; -} - -export function FeedItemsDialog({ open, onOpenChange, feedIds, appName }: FeedItemsDialogProps) { - const t = useTranslations("workflow"); - const [feeds, setFeeds] = useState([]); - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(false); - const [fetching, setFetching] = useState(false); - const [selectedFeedId, setSelectedFeedId] = useState("all"); - const [page, setPage] = useState(1); - const [total, setTotal] = useState(0); - const pageSize = 20; - - const loadFeeds = useCallback(async () => { - if (feedIds.length === 0) return; - - try { - const feedPromises = feedIds.map((id) => - axios.get(`/api/v1/subscription-agent/feeds/${id}`) - ); - const responses = await Promise.all(feedPromises); - setFeeds(responses.map((r) => r.data)); - } catch (error: any) { - console.error("Failed to load feeds:", error); - } - }, [feedIds]); - - const loadItems = useCallback(async () => { - if (feedIds.length === 0) return; - - setLoading(true); - try { - const targetFeedIds = selectedFeedId === "all" ? feedIds : [selectedFeedId]; - const allItems: FeedItem[] = []; - let totalCount = 0; - - for (const feedId of targetFeedIds) { - const response = await axios.get(`/api/v1/subscription-agent/feeds/${feedId}/items`, { - params: { page, pageSize }, - }); - allItems.push(...response.data.items); - totalCount += response.data.pagination?.total || 0; - } - - // 按发布时间排序 - allItems.sort( - (a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime() - ); - - setItems(allItems); - setTotal(totalCount); - } catch (error: any) { - toast.error(error.response?.data?.message || t("loadContentFailed")); - } finally { - setLoading(false); - } - }, [feedIds, selectedFeedId, page, pageSize, t]); - - const handleFetch = async () => { - setFetching(true); - try { - await axios.post("/api/v1/subscription-agent/fetch", { feedIds }); - toast.success(t("fetchTaskStarted")); - // 延迟刷新列表 - setTimeout(() => { - loadItems(); - }, 2000); - } catch (error: any) { - toast.error(error.response?.data?.message || t("triggerFetchFailed")); - } finally { - setFetching(false); - } - }; - - useEffect(() => { - if (open && feedIds.length > 0) { - loadFeeds(); - loadItems(); - } - }, [open, feedIds, loadFeeds, loadItems]); - - useEffect(() => { - if (open) { - setPage(1); - } - }, [selectedFeedId, open]); - - const totalPages = Math.ceil(total / pageSize); - - return ( - - - - - {t("subscriptionContent")} {appName && `- ${appName}`} - - - -
-
- {t("feedSource")}: - -
- -
- - -
-
- -
- - - - {t("title")} - {t("publishedAt")} - {t("link")} - - - - {loading ? ( - - - - - - ) : items.length === 0 ? ( - - - {t("noContent")} - - - ) : ( - items.map((item) => ( - - -
{item.title}
- {item.summary && ( -
- {item.summary} -
- )} -
- - {new Date(item.published_at).toLocaleString("zh-CN", { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - })} - - - - - - -
- )) - )} -
-
-
- - {totalPages > 1 && ( -
-
- {t("paginationInfo", { total, page, totalPages })} -
-
- - -
-
- )} -
-
- ); -} diff --git a/app/apps/components/subscription-agent/ScheduleConfigSection.tsx b/app/apps/components/subscription-agent/ScheduleConfigSection.tsx deleted file mode 100644 index 7f1ace9..0000000 --- a/app/apps/components/subscription-agent/ScheduleConfigSection.tsx +++ /dev/null @@ -1,130 +0,0 @@ -"use client"; - -import { useTranslations } from "next-intl"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import type { ScheduleSettings } from "@/types/subscription-agent"; - -interface ScheduleConfigSectionProps { - schedule: ScheduleSettings; - onChange: (schedule: ScheduleSettings) => void; -} - -// Generate hour options (00-23) -const hours = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, "0")); - -// Generate minute options (00, 15, 30, 45) -const minutes = ["00", "15", "30", "45"]; - -export function ScheduleConfigSection({ schedule, onChange }: ScheduleConfigSectionProps) { - const t = useTranslations("apps"); - - const [hour, minute] = (schedule.time || "10:00").split(":"); - - const handleTimeChange = (newHour: string, newMinute: string) => { - onChange({ - ...schedule, - time: `${newHour}:${newMinute}`, - }); - }; - - return ( -
- - - {/* Enable/Disable Switch */} -
-
- -

{t("enableScheduleDesc")}

-
- - onChange({ - ...schedule, - enabled: checked, - }) - } - /> -
- - {/* Time and Report Type Selection - Only show when enabled */} - {schedule.enabled && ( -
- {/* Schedule Time */} -
-
- -
- - : - -
-
- - {/* Report Type */} -
- - -
-
- - {/* Timezone - Read only */} -
- {t("timezone")}: - {schedule.timezone || "Asia/Shanghai"} -
-
- )} -
- ); -} diff --git a/app/apps/components/subscription-agent/SummaryDetailDialog.tsx b/app/apps/components/subscription-agent/SummaryDetailDialog.tsx deleted file mode 100644 index bdf356d..0000000 --- a/app/apps/components/subscription-agent/SummaryDetailDialog.tsx +++ /dev/null @@ -1,272 +0,0 @@ -"use client"; - -import { useState, useEffect, useCallback } from "react"; -import { useTranslations } from "next-intl"; -import axios from "@/lib/axios"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Loader2, RefreshCw, ExternalLink } from "lucide-react"; -import { toast } from "sonner"; -import ReactMarkdown from "react-markdown"; -import type { Summary } from "@/types/subscription-agent"; - -// 后端可能返回的原始格式(与 Summary 类型有差异) -interface RawSummary { - id?: string; - summaryId?: string; - type: "daily" | "weekly"; - status: "pending" | "processing" | "completed" | "failed"; - period_start?: string; - period_end?: string; - period?: { - start: string; - end: string; - }; - platform_filter?: "all" | "youtube" | "twitter"; - item_count?: number; - itemCount?: number; - summary_text?: string; - summaryText?: string; - highlights?: Summary["highlights"]; - llm_model?: string; - llm_tokens_input?: number; - llm_tokens_output?: number; - triggered_by?: "scheduled" | "manual"; - triggeredBy?: "scheduled" | "manual"; - created_at?: string; - createdAt?: string; - updated_at?: string; -} - -// 将原始数据转换为标准 Summary 格式 -function normalizeSummary(raw: RawSummary): Summary { - return { - id: raw.id || raw.summaryId || "", - type: raw.type, - status: raw.status, - period_start: raw.period_start || raw.period?.start || "", - period_end: raw.period_end || raw.period?.end || "", - platform_filter: raw.platform_filter || "all", - item_count: raw.item_count ?? raw.itemCount ?? 0, - summary_text: raw.summary_text || raw.summaryText, - highlights: raw.highlights, - llm_model: raw.llm_model, - llm_tokens_input: raw.llm_tokens_input, - llm_tokens_output: raw.llm_tokens_output, - triggered_by: raw.triggered_by || raw.triggeredBy || "manual", - created_at: raw.created_at || raw.createdAt || new Date().toISOString(), - updated_at: raw.updated_at, - }; -} - -interface SummaryDetailDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - summaryId: string | null; -} - -const typeColorMap: Record = { - daily: "bg-blue-100 text-blue-800", - weekly: "bg-purple-100 text-purple-800", -}; - -const statusColorMap: Record = { - pending: "bg-yellow-100 text-yellow-800", - processing: "bg-blue-100 text-blue-800", - completed: "bg-green-100 text-green-800", - failed: "bg-red-100 text-red-800", -}; - -export function SummaryDetailDialog({ open, onOpenChange, summaryId }: SummaryDetailDialogProps) { - const t = useTranslations("workflow"); - const tc = useTranslations("common"); - const [summary, setSummary] = useState(null); - const [loading, setLoading] = useState(false); - - const typeLabelMap: Record = { - daily: t("dailyReport"), - weekly: t("weeklyReport"), - }; - - const statusLabelMap: Record = { - pending: t("pending"), - processing: t("generating"), - completed: t("completed"), - failed: t("failed"), - }; - - const loadSummary = useCallback(async () => { - if (!summaryId) return; - - setLoading(true); - try { - const response = await axios.get( - `/api/v1/subscription-agent/summaries/${summaryId}` - ); - setSummary(normalizeSummary(response.data)); - } catch (error: any) { - toast.error(error.response?.data?.message || t("loadReportFailed")); - } finally { - setLoading(false); - } - }, [summaryId, t]); - - useEffect(() => { - if (open && summaryId) { - loadSummary(); - } - }, [open, summaryId, loadSummary]); - - useEffect(() => { - if (!open) { - setSummary(null); - } - }, [open]); - - // 自动轮询处理中的报告 - useEffect(() => { - if (!open || !summary) return; - - if (summary.status === "pending" || summary.status === "processing") { - const timer = setInterval(() => { - loadSummary(); - }, 5000); - - return () => clearInterval(timer); - } - }, [open, summary, loadSummary]); - - return ( - - - -
- - {t("reportDetail")} - {summary && ( - <> - - {typeLabelMap[summary.type]} - - - {statusLabelMap[summary.status]} - - - )} - - -
-
- -
- {loading && !summary ? ( -
- -
- ) : summary ? ( -
- {/* 元数据 */} -
-
-
{t("timeRange")}
-
- {summary.period_start - ? new Date(summary.period_start).toLocaleDateString("zh-CN") - : "-"}{" "} - -{" "} - {summary.period_end - ? new Date(summary.period_end).toLocaleDateString("zh-CN") - : "-"} -
-
-
-
{t("contentCount")}
-
- {summary.item_count} {t("items")} -
-
-
-
{t("triggerMethod")}
-
- {summary.triggered_by === "manual" ? t("manual") : t("scheduled")} -
-
-
-
{t("generatedAt")}
-
- {new Date(summary.created_at).toLocaleString("zh-CN")} -
-
-
- - {/* 处理中状态 */} - {(summary.status === "pending" || summary.status === "processing") && ( -
- - {t("reportGenerating")} -
- )} - - {/* 失败状态 */} - {summary.status === "failed" && ( -
- {summary.item_count === 0 ? ( - t("noContentInPeriod") - ) : ( - {t("reportGenerationFailed")} - )} -
- )} - - {/* 报告内容 */} - {summary.status === "completed" && summary.summary_text && ( -
- {summary.summary_text} -
- )} - - {/* 高亮列表 */} - {summary.status === "completed" && - summary.highlights && - summary.highlights.length > 0 && ( -
-

{t("featuredContent")}

-
- {summary.highlights.map((highlight, index) => ( -
-
- - {highlight.platform} - - - {highlight.title} - - -
-

{highlight.summary}

-
- ))} -
-
- )} -
- ) : ( -
{tc("noData")}
- )} -
-
-
- ); -} diff --git a/app/apps/components/subscription-agent/SummaryListDialog.tsx b/app/apps/components/subscription-agent/SummaryListDialog.tsx deleted file mode 100644 index d144883..0000000 --- a/app/apps/components/subscription-agent/SummaryListDialog.tsx +++ /dev/null @@ -1,352 +0,0 @@ -"use client"; - -import { useState, useEffect, useCallback } from "react"; -import { useTranslations } from "next-intl"; -import axios from "@/lib/axios"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Loader2, RefreshCw, Plus, ChevronDown } from "lucide-react"; -import { toast } from "sonner"; -import { SummaryDetailDialog } from "./SummaryDetailDialog"; -import type { Summary, SummaryListResponse } from "@/types/subscription-agent"; - -interface SummaryListDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - feedIds: string[]; - appName?: string; - topic?: string; - webhookUrl?: string; -} - -const typeColorMap: Record = { - daily: "bg-blue-100 text-blue-800", - weekly: "bg-purple-100 text-purple-800", -}; - -const statusColorMap: Record = { - pending: "bg-yellow-100 text-yellow-800", - processing: "bg-blue-100 text-blue-800", - completed: "bg-green-100 text-green-800", - failed: "bg-red-100 text-red-800", -}; - -export function SummaryListDialog({ - open, - onOpenChange, - feedIds, - appName, - topic, - webhookUrl, -}: SummaryListDialogProps) { - const t = useTranslations("workflow"); - const [summaries, setSummaries] = useState([]); - const [loading, setLoading] = useState(false); - const [generating, setGenerating] = useState(false); - - const typeLabelMap: Record = { - daily: t("dailyReport"), - weekly: t("weeklyReport"), - }; - - const statusLabelMap: Record = { - pending: t("pending"), - processing: t("generating"), - completed: t("completed"), - failed: t("failed"), - }; - const [typeFilter, setTypeFilter] = useState("all"); - const [statusFilter, setStatusFilter] = useState("all"); - const [page, setPage] = useState(1); - const [total, setTotal] = useState(0); - const pageSize = 20; - - // 详情弹窗 - const [detailOpen, setDetailOpen] = useState(false); - const [selectedSummaryId, setSelectedSummaryId] = useState(null); - - const loadSummaries = useCallback(async () => { - if (!feedIds || feedIds.length === 0) return; - - setLoading(true); - try { - const params: Record = { - page: String(page), - pageSize: String(pageSize), - feedIds: feedIds.join(","), - }; - - if (typeFilter !== "all") { - params.type = typeFilter; - } - - if (statusFilter !== "all") { - params.status = statusFilter; - } - - const response = await axios.get( - "/api/v1/subscription-agent/summaries", - { params } - ); - - setSummaries(response.data.data || []); - setTotal(response.data.pagination?.total || 0); - } catch (error: any) { - toast.error(error.response?.data?.message || t("loadReportListFailed")); - } finally { - setLoading(false); - } - }, [feedIds, typeFilter, statusFilter, page, t]); - - const handleGenerateDaily = async () => { - setGenerating(true); - try { - const response = await axios.post("/api/v1/subscription-agent/summaries/generate/today", { - feedIds, - topic: topic || undefined, - webhook_url: webhookUrl || undefined, - }); - toast.success(t("dailyReportStarted")); - setSelectedSummaryId(response.data.summaryId); - setDetailOpen(true); - // 刷新列表 - loadSummaries(); - } catch (error: any) { - toast.error(error.response?.data?.message || t("generateDailyFailed")); - } finally { - setGenerating(false); - } - }; - - const handleGenerateWeekly = async () => { - setGenerating(true); - try { - const response = await axios.post("/api/v1/subscription-agent/summaries/generate/week", { - feedIds, - topic: topic || undefined, - webhook_url: webhookUrl || undefined, - }); - toast.success(t("weeklyReportStarted")); - setSelectedSummaryId(response.data.summaryId); - setDetailOpen(true); - // 刷新列表 - loadSummaries(); - } catch (error: any) { - toast.error(error.response?.data?.message || t("generateWeeklyFailed")); - } finally { - setGenerating(false); - } - }; - - const handleRowClick = (summary: Summary) => { - setSelectedSummaryId(summary.id); - setDetailOpen(true); - }; - - useEffect(() => { - if (open && feedIds && feedIds.length > 0) { - loadSummaries(); - } - }, [open, feedIds, loadSummaries]); - - useEffect(() => { - if (open) { - setPage(1); - } - }, [typeFilter, statusFilter, open]); - - const totalPages = Math.ceil(total / pageSize); - - return ( - <> - - - - - {t("reportList")} {appName && `- ${appName}`} - - - -
-
- - - -
- -
- - - - - - - {t("generateDaily")} - - - {t("generateWeekly")} - - - - - -
-
- -
- - - - {t("type")} - {t("status")} - {t("timeRange")} - {t("contentCount")} - {t("triggerMethod")} - {t("generatedAt")} - - - - {loading ? ( - - - - - - ) : summaries.length === 0 ? ( - - - {t("noReports")} - - - ) : ( - summaries.map((summary) => ( - handleRowClick(summary)} - > - - - {typeLabelMap[summary.type]} - - - - - {statusLabelMap[summary.status]} - - - - {new Date(summary.period_start).toLocaleDateString("zh-CN")} -{" "} - {new Date(summary.period_end).toLocaleDateString("zh-CN")} - - {summary.item_count} - - {summary.triggered_by === "manual" ? t("manual") : t("scheduled")} - - - {new Date(summary.created_at).toLocaleString("zh-CN", { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - })} - - - )) - )} - -
-
- - {totalPages > 1 && ( -
-
- {t("paginationInfo", { total, page, totalPages })} -
-
- - -
-
- )} -
-
- - - - ); -} diff --git a/app/apps/page.tsx b/app/apps/page.tsx index 7708a31..f1e027e 100644 --- a/app/apps/page.tsx +++ b/app/apps/page.tsx @@ -2,7 +2,7 @@ import { Fragment, useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useTranslations } from "next-intl"; -import { triggerLabel } from "@/lib/appTrigger"; +import { presentationModeLabel } from "@/lib/appTrigger"; import { canEditApp } from "@/lib/appPermissions"; import AppAvatarPicker from "./components/AppAvatarPicker"; import AppAvatar from "./components/AppAvatar"; @@ -48,10 +48,9 @@ import { Network, FileText, Code, - List, - X, Sparkles, LayoutGrid, + List, } from "lucide-react"; import { appStatusBadge, type ReviewStatus } from "@/lib/reviewStatus"; import Link from "next/link"; @@ -64,17 +63,13 @@ import { checkSuperAdmin } from "@/lib/clientPermissions"; import WorkflowEditor from "./components/WorkflowEditor"; import { syncFormToWorkflow } from "@/lib/workflowUtils"; import { WorkflowConfig } from "@/types/workflow"; -import { FeedItemsDialog } from "./components/subscription-agent/FeedItemsDialog"; -import { SummaryListDialog } from "./components/subscription-agent/SummaryListDialog"; -import { ScheduleConfigSection } from "./components/subscription-agent/ScheduleConfigSection"; -import type { StreamFeedFormItem, ScheduleSettings } from "@/types/subscription-agent"; // 类型定义 interface App { id: number; name: string; description: string; - app_type: "Chat" | "Subscription" | "Email" | "Custom" | "Tool" | "Plugin"; + app_type: "Chat" | "Custom"; platform: "Web" | "Wechat" | "Plugin" | "Feishu" | "iOS" | "Android"; avatar_url?: string | null; user_id: number; @@ -122,7 +117,7 @@ const platformIcons: Record = { Android: Smartphone, }; -// 触发方式与平台标签一律中性灰(Badge 的 secondary)。 +// 展示方式与平台标签一律中性灰(Badge 的 secondary)。 // // 原先每个取值一种颜色:紫、绿、黄、蓝、翠、橙……一屏卡片下来像调色板,而这些 // 颜色不承载任何含义——"聊天"是紫的、"Web"也是紫的,读者得先学会一套配色表才 @@ -130,9 +125,6 @@ const platformIcons: Record = { // 颜色留给它们才有对比度。详情页那两个标签一直是中性的 outline,这里跟它对齐。 -// 判断是否为订阅聚合应用 -const isStreamApp = (app: App) => app.app_type === "Subscription"; - export default function AppsPage() { const router = useRouter(); const { user } = useCurrentUser(); @@ -206,13 +198,6 @@ export default function AppsPage() { const [embedDialogOpen, setEmbedDialogOpen] = useState(false); const [embedAppId, setEmbedAppId] = useState(""); - // Stream Agent 相关状态 - const [feedItemsDialogOpen, setFeedItemsDialogOpen] = useState(false); - const [summaryListDialogOpen, setSummaryListDialogOpen] = useState(false); - const [selectedStreamApp, setSelectedStreamApp] = useState(null); - const [streamFeeds, setStreamFeeds] = useState([]); - const [newStreamUrl, setNewStreamUrl] = useState(""); - const [addingFeed, setAddingFeed] = useState(false); const [viewMode, setViewMode] = useState<"table" | "grid">("grid"); const [autoSelectDatasets, setAutoSelectDatasets] = useState(true); @@ -230,7 +215,7 @@ export default function AppsPage() { const [formData, setFormData] = useState<{ name: string; description: string; - app_type: "Chat" | "Subscription" | "Email" | "Custom" | "Tool" | "Plugin"; + app_type: "Chat" | "Custom"; platform: "Web" | "Wechat" | "Plugin" | "Feishu" | "iOS" | "Android"; // 空串 = 用户主动清空(后端据此落 NULL);null = 本来就没设过 avatar_url: string | null; @@ -315,52 +300,6 @@ export default function AppsPage() { } }, [formData.platform, dialogOpen, loadWechatAgents]); - // 添加 Stream URL(立即请求 API 创建订阅) - const handleAddStreamUrl = useCallback(async () => { - if (!newStreamUrl) return; - setAddingFeed(true); - try { - const res = await axios.post("/api/v1/subscription-agent/feeds", { - url: newStreamUrl, - }); - const feedId = res.data.id; - setStreamFeeds((prev) => { - // 检查是否已存在,避免重复添加 - if (prev.some((f) => f.id === feedId)) { - return prev; - } - return [ - ...prev, - { - id: feedId, - url: res.data.source_url || res.data.url || newStreamUrl, - name: res.data.name, - platform: res.data.platform, - }, - ]; - }); - setNewStreamUrl(""); - toast.success(t("subscriptionAdded", { name: res.data.name || newStreamUrl })); - } catch (error: any) { - toast.error(error.response?.data?.message || t("addSubscriptionFailed")); - } finally { - setAddingFeed(false); - } - }, [newStreamUrl]); - - // 删除订阅(从本地列表移除) - const handleRemoveStreamFeed = useCallback((feedId: string) => { - setStreamFeeds((prev) => prev.filter((f) => f.id !== feedId)); - }, []); - - // Default schedule settings - const defaultScheduleSettings: ScheduleSettings = { - enabled: false, - time: "10:00", - timezone: "Asia/Shanghai", - report_type: "daily", - }; - // 打开创建对话框 const handleCreate = useCallback(() => { setEditingApp(null); @@ -376,8 +315,6 @@ export default function AppsPage() { settings: {}, is_default: false, }); - setStreamFeeds([]); - setNewStreamUrl(""); setAutoSelectDatasets(true); setDialogOpen(true); }, []); @@ -504,7 +441,7 @@ export default function AppsPage() { }, [selectedTemplate, handleCreateQualityAgent]); // 打开编辑对话框 - const handleEdit = useCallback(async (app: App) => { + const handleEdit = useCallback((app: App) => { setEditingApp(app); const datasetIds = app.dataset_ids || []; setFormData({ @@ -522,34 +459,6 @@ export default function AppsPage() { // 如果 dataset_ids 为空,则自动选择知识库为 true,否则为 false setAutoSelectDatasets(datasetIds.length === 0); - // 如果是 Stream 应用,加载已有的订阅信息 - if (isStreamApp(app) && app.settings?.stream_feed_ids?.length > 0) { - // 优先从 settings.stream_feeds 读取(包含完整的 url 信息) - if (app.settings?.stream_feeds?.length > 0) { - setStreamFeeds(app.settings.stream_feeds); - } else { - // 兼容旧数据:从 API 获取 - try { - const feedPromises = app.settings.stream_feed_ids.map((id: string) => - axios.get(`/api/v1/subscription-agent/feeds/${id}`) - ); - const responses = await Promise.all(feedPromises); - setStreamFeeds( - responses.map((r) => ({ - id: r.data.id, - url: r.data.source_url || r.data.url || "", - name: r.data.name, - platform: r.data.platform, - })) - ); - } catch { - setStreamFeeds([]); - } - } - } else { - setStreamFeeds([]); - } - setNewStreamUrl(""); setDialogOpen(true); }, []); @@ -563,40 +472,14 @@ export default function AppsPage() { try { setSubmitting(true); - let submitData: any; - - // Stream 应用特殊处理 - if (formData.app_type === "Subscription") { - submitData = { - ...formData, - settings: { - ...formData.settings, - stream_feed_ids: streamFeeds.map((f) => f.id), - // 保存完整的 feed 信息,因为外部 API 可能不返回 url - stream_feeds: streamFeeds.map((f) => ({ - id: f.id, - url: f.url, - name: f.name, - platform: f.platform, - })), - }, - }; - } else { - // 同步表单数据到工作流配置 - const workflow = syncFormToWorkflow( - { ...formData }, - editingApp?.settings?.workflow, - datasets - ); - - submitData = { - ...formData, - settings: { - ...formData.settings, - workflow, - }, - }; - } + const workflow = syncFormToWorkflow({ ...formData }, editingApp?.settings?.workflow, datasets); + const submitData = { + ...formData, + settings: { + ...formData.settings, + workflow, + }, + }; if (editingApp) { // 更新 @@ -621,7 +504,7 @@ export default function AppsPage() { } finally { setSubmitting(false); } - }, [formData, editingApp, loadApps, datasets, streamFeeds]); + }, [formData, editingApp, loadApps, datasets]); // 提交应用审核(draft/rejected → pending_review;建即 draft,仅 owner 可测) const handleSubmitAppReview = useCallback( @@ -643,17 +526,6 @@ export default function AppsPage() { if (!deletingApp) return; try { - // 如果是 Stream 应用,同时删除关联的所有订阅 - if (isStreamApp(deletingApp) && deletingApp.settings?.stream_feed_ids?.length > 0) { - for (const feedId of deletingApp.settings.stream_feed_ids) { - try { - await axios.delete(`/api/v1/subscription-agent/feeds/${feedId}`); - } catch (error) { - console.error(`删除订阅 ${feedId} 失败:`, error); - } - } - } - await axios.delete(`/api/v1/apps/${deletingApp.id}`); toast.success(t("appDeleted")); setDeleteConfirmOpen(false); @@ -846,7 +718,7 @@ export default function AppsPage() { - {triggerLabel(app.app_type, t)} + {presentationModeLabel(app.app_type, t)} @@ -884,68 +756,24 @@ export default function AppsPage() {
- {/* Stream 应用特有操作 */} - {isStreamApp(app) && ( - <> - - - - - - {t("viewContent")} - - - - - - - - {t("viewReport")} - - - - )} - {/* 非 Stream 应用显示工作流按钮 */} - {!isStreamApp(app) && ( - - - - - - {t("workflowConfig")} - - - )} + + + + + + {t("workflowConfig")} + + {canSubmitAppReview(app) && ( @@ -1078,7 +906,7 @@ export default function AppsPage() { ) : null; })()} - {triggerLabel(app.app_type, t)} + {presentationModeLabel(app.app_type, t)} @@ -1117,69 +945,25 @@ export default function AppsPage() { {new Date(app.updated_at || app.created_at).toLocaleDateString()}
- {isStreamApp(app) && ( - <> - - - - - - {t("viewContent")} - - - - - - - - {t("viewReport")} - - - - )} - {!isStreamApp(app) && ( - - - - - - {t("workflowConfig")} - - - )} + + + + + + {t("workflowConfig")} + + {canSubmitAppReview(app) && ( @@ -1363,28 +1147,13 @@ export default function AppsPage() {

{t("setAsDefaultDesc")}

- {/* - 触发方式 / 平台 / AI 模型 同一行。AI 模型对 Subscription、Custom - 不适用,隐藏时列数跟着降到 2——否则会空出三分之一,看着像少了个字段。 - */} -
+
@@ -1442,7 +1195,7 @@ export default function AppsPage() {
- {formData.app_type !== "Subscription" && formData.app_type !== "Custom" && ( + {formData.app_type === "Chat" && (
setNewStreamUrl(e.target.value)} - placeholder={t("subscriptionUrlPlaceholder")} - disabled={addingFeed} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleAddStreamUrl(); - } - }} - /> - -
-

{t("subscriptionUrlHelp")}

- {streamFeeds.length > 0 && ( -
- {streamFeeds.map((feed) => ( -
-
- - {feed.platform} - - - {feed.url || feed.name || feed.id} - -
- -
- ))} -
- )} - -
- - - setFormData({ - ...formData, - settings: { - ...formData.settings, - topic: e.target.value, - }, - }) - } - placeholder={t("topicFilterPlaceholder")} - maxLength={200} - /> -

{t("topicFilterHelp")}

-
- -
- - - setFormData({ - ...formData, - settings: { - ...formData.settings, - webhook_url: e.target.value, - }, - }) - } - placeholder={t("webhookUrlPlaceholder")} - type="url" - /> -

{t("webhookUrlHelp")}

-
- - {/* Schedule Configuration */} - - setFormData({ - ...formData, - settings: { - ...formData.settings, - schedule, - }, - }) - } - /> -
- )} - {/* Custom 应用:选择要渲染的自定义视图(选项来自前端注册表,写死) */} {formData.app_type === "Custom" && (
@@ -1674,9 +1319,7 @@ export default function AppsPage() { 新建态没有 editingApp、也就没有 id 可跳,整块不显示——角色在应用 建好之后编辑(后端建应用时已自动铺一份起始角色)。 */} - {editingApp && - formData.app_type !== "Subscription" && - formData.app_type !== "Custom" && ( + {editingApp && formData.app_type === "Chat" && (
@@ -1694,9 +1337,7 @@ export default function AppsPage() {
)} - {/* 关联知识库(Stream 应用不显示) */} - {formData.app_type !== "Subscription" && ( -
+
)} -
- )} +
); } diff --git a/docs/subscription-agent.md b/docs/subscription-agent.md deleted file mode 100644 index 428aa7f..0000000 --- a/docs/subscription-agent.md +++ /dev/null @@ -1,837 +0,0 @@ -# StreamCache API 文档 - -Subscription Agent 是一个 RSS 聚合服务,支持 YouTube 频道和 Twitter 用户订阅,并提供 AI 生成的内容摘要。 - -## 基础信息 - -- **Base URL**: `http://your-server:3000` -- **Content-Type**: `application/json` -- **API Version**: `2.0.0` - ---- - -## 认证 - -所有 `/api/*` 端点需要 Bearer Token 认证。 - -### 请求头 - -``` -Authorization: Bearer -``` - -### 示例 - -```bash -curl -H "Authorization: Bearer your-api-key" \ - http://localhost:3000/api/feeds -``` - -### 认证失败响应 - -```json -{ - "code": "UNAUTHORIZED", - "message": "Unauthorized" -} -``` - -**HTTP Status**: `401` - ---- - -## 根端点 - -### API 信息 - -**GET** `/` - -返回 API 基本信息和可用端点列表。 - -#### 成功响应 (200) - -```json -{ - "name": "StreamCache API", - "version": "2.0.0", - "description": "RSS aggregation service for YouTube and Twitter content with LLM-powered summaries", - "endpoints": { - "health": "/health", - "feeds": "/api/feeds", - "fetch": "/api/fetch", - "content_processing": "/api/feed-items/process", - "summaries": "/api/summaries", - "daily_summary": "/api/summaries/generate/today", - "weekly_summary": "/api/summaries/generate/week", - "summary_stats": "/api/summaries/stats" - } -} -``` - ---- - -## 公开端点(无需认证) - -### 健康检查 - -| 方法 | 端点 | 说明 | -|------|------|------| -| GET | `/health` | 基础健康检查 | -| GET | `/health/detailed` | 详细健康检查(含依赖状态) | -| GET | `/health/ready` | Kubernetes 就绪探针 | -| GET | `/health/live` | Kubernetes 存活探针 | - -#### GET /health - 基础健康检查 - -```json -{ - "status": "ok", - "timestamp": "2024-01-01T00:00:00.000Z" -} -``` - -#### GET /health/detailed - 详细健康检查 - -```json -{ - "status": "healthy", - "timestamp": "2024-01-01T00:00:00.000Z", - "checks": { - "database": { - "status": "ok", - "latency": 5 - }, - "rsshub": { - "status": "ok", - "latency": 120 - }, - "scheduler": { - "status": "running" - } - } -} -``` - -| 字段 | 说明 | -|------|------| -| status | `healthy` 或 `degraded`(有依赖不可用时) | -| checks.database.status | `ok` 或 `error` | -| checks.database.latency | 数据库响应延迟(毫秒) | -| checks.rsshub.status | `ok` 或 `error` | -| checks.rsshub.latency | RSSHub 响应延迟(毫秒) | -| checks.scheduler.status | `running` 或 `stopped` | - -**HTTP Status**: `200`(healthy)或 `503`(degraded) - -#### GET /health/ready - 就绪探针 - -```json -{ - "ready": true, - "timestamp": "2024-01-01T00:00:00.000Z" -} -``` - -失败时(503): -```json -{ - "ready": false, - "timestamp": "2024-01-01T00:00:00.000Z", - "error": "Database not ready" -} -``` - -#### GET /health/live - 存活探针 - -```json -{ - "alive": true, - "timestamp": "2024-01-01T00:00:00.000Z" -} -``` - ---- - -## 订阅管理 - -### 创建订阅 - -**POST** `/api/feeds` - -创建一个新的 RSS 订阅。如果订阅已存在,返回已有的订阅记录。 - -#### 请求体 - -```json -{ - "url": "https://www.youtube.com/channel/UCxxxxxx" -} -``` - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| url | string | 是 | YouTube 频道 URL 或 Twitter 用户 URL | - -**支持的 URL 格式**: -- YouTube: `https://www.youtube.com/channel/UCxxxxxx`(仅支持 `/channel/` 格式) -- Twitter: `https://twitter.com/username` 或 `https://x.com/username` - -#### 成功响应 (200) - -```json -{ - "id": "550e8400-e29b-41d4-a716-446655440000", - "platform": "youtube", - "source_url": "https://www.youtube.com/channel/UCxxxxxx", - "rsshub_route": "/youtube/channel/UCxxxxxx", - "status": "active", - "subscribed_at": "2024-01-01T00:00:00.000Z", - "last_fetch_at": null, - "last_fetch_status": null -} -``` - -| 字段 | 类型 | 说明 | -|------|------|------| -| id | UUID | 订阅唯一标识 | -| platform | string | 平台类型:`youtube` 或 `twitter` | -| source_url | string | 原始订阅 URL | -| rsshub_route | string | RSSHub 路由路径 | -| status | string | 状态:`active` / `disabled` / `error` | -| subscribed_at | ISO8601 | 订阅时间 | -| last_fetch_at | ISO8601 \| null | 最后抓取时间 | -| last_fetch_status | string \| null | 最后抓取状态:`success` / `fail` / `null` | - -#### 错误响应 (400) - -```json -{ - "code": "invalid_url", - "message": "不支持的 YouTube URL 格式", - "platform": "youtube", - "url_help": "请使用「分享频道」获取正确的 URL 格式:https://www.youtube.com/channel/UCxxxxxx" -} -``` - ---- - -### 订阅列表 - -**GET** `/api/feeds` - -获取所有订阅列表。 - -#### 查询参数 - -| 参数 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| page | number | 1 | 页码(最小 1) | -| pageSize | number | 20 | 每页数量(1-100) | - -#### 成功响应 (200) - -```json -{ - "data": [ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "platform": "youtube", - "source_url": "https://www.youtube.com/channel/UCxxxxxx", - "rsshub_route": "/youtube/channel/UCxxxxxx", - "status": "active", - "subscribed_at": "2024-01-01T00:00:00.000Z", - "last_fetch_at": "2024-01-01T12:00:00.000Z", - "last_fetch_status": "success" - } - ], - "pagination": { - "page": 1, - "pageSize": 20, - "total": 1 - } -} -``` - ---- - -### 订阅详情 - -**GET** `/api/feeds/:id` - -获取单个订阅的详细信息。 - -#### 路径参数 - -| 参数 | 类型 | 说明 | -|------|------|------| -| id | UUID | 订阅 ID | - -#### 成功响应 (200) - -```json -{ - "id": "550e8400-e29b-41d4-a716-446655440000", - "platform": "youtube", - "source_url": "https://www.youtube.com/channel/UCxxxxxx", - "rsshub_route": "/youtube/channel/UCxxxxxx", - "rsshub_query": null, - "status": "active", - "subscribed_at": "2024-01-01T00:00:00.000Z", - "last_fetch_at": "2024-01-01T12:00:00.000Z", - "last_fetch_status": "success" -} -``` - -| 字段 | 类型 | 说明 | -|------|------|------| -| rsshub_query | object \| null | RSSHub 额外查询参数 | - -#### 错误响应 (400) - -```json -{ - "code": "invalid_id", - "message": "Invalid feed ID format" -} -``` - -#### 错误响应 (404) - -```json -{ - "code": "not_found", - "message": "Feed not found" -} -``` - ---- - -### 删除订阅 - -**DELETE** `/api/feeds/:id` - -删除一个订阅。 - -#### 路径参数 - -| 参数 | 类型 | 说明 | -|------|------|------| -| id | UUID | 订阅 ID | - -#### 查询参数 - -| 参数 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| hard | string | "false" | `"true"` 为硬删除(删除所有关联内容),`"false"` 为软删除(标记为 disabled) | - -#### 成功响应 (200) - -```json -{ - "message": "Feed deleted successfully", - "hard": false -} -``` - -| 字段 | 类型 | 说明 | -|------|------|------| -| message | string | 操作结果消息 | -| hard | boolean | 是否为硬删除 | - ---- - -### 订阅内容列表 - -**GET** `/api/feeds/:id/items` - -获取订阅下的已抓取内容。 - -#### 路径参数 - -| 参数 | 类型 | 说明 | -|------|------|------| -| id | UUID | 订阅 ID | - -#### 查询参数 - -| 参数 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| from | ISO8601 | - | 起始时间(可选) | -| to | ISO8601 | - | 结束时间(可选) | -| page | number | 1 | 页码(最小 1) | -| pageSize | number | 50 | 每页数量(1-100) | - -#### 成功响应 (200) - -```json -{ - "items": [ - { - "id": "item-uuid", - "title": "Video Title", - "link": "https://www.youtube.com/watch?v=xxxxx", - "summary": "Video description...", - "author": "Channel Name", - "published_at": "2024-01-01T10:00:00.000Z", - "fetched_at": "2024-01-01T12:00:00.000Z" - } - ], - "pagination": { - "page": 1, - "pageSize": 50, - "total": 100 - }, - "query": { - "from": "2024-01-01T00:00:00.000Z", - "to": null - } -} -``` - -| 字段 | 类型 | 说明 | -|------|------|------| -| items | array | 内容项列表 | -| items[].id | UUID | 内容项 ID | -| items[].title | string | 标题 | -| items[].link | string | 原始链接 | -| items[].summary | string \| null | 摘要/描述 | -| items[].author | string \| null | 作者 | -| items[].published_at | ISO8601 | 发布时间 | -| items[].fetched_at | ISO8601 | 抓取时间 | -| query.from | ISO8601 \| null | 请求的起始时间 | -| query.to | ISO8601 \| null | 请求的结束时间 | - -#### 数据不完整警告 - -当请求的时间范围早于订阅时间时,响应会包含警告: - -```json -{ - "items": [...], - "pagination": {...}, - "query": {...}, - "warning": { - "code": "data_incomplete", - "message": "请求的时间范围部分在订阅时间之前,该时段数据可能不完整", - "subscribed_at": "2024-01-01T00:00:00.000Z", - "incomplete_range": { - "from": "2023-12-01T00:00:00.000Z", - "to": "2024-01-01T00:00:00.000Z" - } - } -} -``` - ---- - -## 报告管理 - -### 生成日报 - -**POST** `/api/summaries/generate/today` - -手动触发生成过去 24 小时的日报。**立即返回,后台异步处理。** - -#### 请求体(可选) - -```json -{ - "feedIds": ["550e8400-e29b-41d4-a716-446655440000"] -} -``` - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| feedIds | UUID[] | 否 | 指定要汇总的订阅 ID 数组。不传或传空数组则汇总所有订阅。 | - -#### 成功响应 (200) - -```json -{ - "type": "daily", - "status": "pending", - "summaryId": "550e8400-e29b-41d4-a716-446655440001", - "feedIds": ["550e8400-e29b-41d4-a716-446655440000"], - "period": { - "start": "2024-01-01T00:00:00.000Z", - "end": "2024-01-02T00:00:00.000Z" - }, - "message": "Daily summary generation started in background" -} -``` - -| 字段 | 类型 | 说明 | -|------|------|------| -| type | string | 报告类型:`daily` | -| status | string | 初始状态:`pending` | -| summaryId | UUID | 报告 ID,可用于查询状态 | -| feedIds | UUID[] \| null | 指定的订阅 ID,`null` 表示所有订阅 | -| period.start | ISO8601 | 统计周期开始时间 | -| period.end | ISO8601 | 统计周期结束时间 | -| message | string | 操作说明 | - -#### 错误响应 (400) - -```json -{ - "code": "validation_error", - "message": "Invalid request body", - "errors": [...] -} -``` - ---- - -### 生成周报 - -**POST** `/api/summaries/generate/week` - -手动触发生成过去 7 天的周报。**立即返回,后台异步处理。** - -#### 请求体(可选) - -```json -{ - "feedIds": ["550e8400-e29b-41d4-a716-446655440000"] -} -``` - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| feedIds | UUID[] | 否 | 指定要汇总的订阅 ID 数组。不传或传空数组则汇总所有订阅。 | - -#### 成功响应 (200) - -```json -{ - "type": "weekly", - "status": "pending", - "summaryId": "550e8400-e29b-41d4-a716-446655440001", - "feedIds": ["550e8400-e29b-41d4-a716-446655440000"], - "period": { - "start": "2023-12-25T00:00:00.000Z", - "end": "2024-01-01T00:00:00.000Z" - }, - "message": "Weekly summary generation started in background" -} -``` - ---- - -### 报告列表 - -**GET** `/api/summaries` - -获取所有报告列表(支持分页和筛选)。 - -#### 查询参数 - -| 参数 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| type | string | - | 报告类型:`daily` 或 `weekly` | -| status | string | - | 状态:`pending` / `processing` / `completed` / `failed` | -| feedIds | string | - | 逗号分隔的订阅 ID 列表(筛选包含这些订阅的报告) | -| page | number | 1 | 页码(最小 1) | -| pageSize | number | 20 | 每页数量(1-100) | - -#### 成功响应 (200) - -```json -{ - "data": [ - { - "id": "summary-uuid", - "type": "daily", - "status": "completed", - "period_start": "2024-01-01T00:00:00.000Z", - "period_end": "2024-01-02T00:00:00.000Z", - "platform_filter": "all", - "item_count": 25, - "triggered_by": "scheduled", - "created_at": "2024-01-02T10:00:00.000Z" - } - ], - "pagination": { - "page": 1, - "pageSize": 20, - "total": 10 - } -} -``` - -| 字段 | 类型 | 说明 | -|------|------|------| -| data[].id | UUID | 报告 ID | -| data[].type | string | 类型:`daily` / `weekly` | -| data[].status | string | 状态:`pending` / `processing` / `completed` / `failed` | -| data[].period_start | ISO8601 | 统计周期开始 | -| data[].period_end | ISO8601 | 统计周期结束 | -| data[].platform_filter | string | 平台筛选:`all` / `youtube` / `twitter` | -| data[].item_count | number | 包含的内容数量 | -| data[].triggered_by | string | 触发方式:`scheduled` / `manual` | -| data[].created_at | ISO8601 | 创建时间 | - ---- - -### 报告详情 - -**GET** `/api/summaries/:id` - -获取单个报告的完整内容。 - -#### 路径参数 - -| 参数 | 类型 | 说明 | -|------|------|------| -| id | UUID | 报告 ID | - -#### 成功响应 (200) - -```json -{ - "id": "summary-uuid", - "type": "daily", - "status": "completed", - "period": { - "start": "2024-01-01T00:00:00.000Z", - "end": "2024-01-02T00:00:00.000Z" - }, - "platform_filter": "all", - "item_count": 25, - "summary_text": "# 日报摘要\n\n## YouTube\n\n...", - "highlights": [ - { - "platform": "youtube", - "title": "重要视频标题", - "link": "https://...", - "summary": "视频要点..." - } - ], - "llm_model": "qwen-max", - "llm_tokens_input": 5000, - "llm_tokens_output": 1200, - "triggered_by": "manual", - "created_at": "2024-01-02T10:00:00.000Z", - "updated_at": "2024-01-02T10:05:00.000Z" -} -``` - -| 字段 | 类型 | 说明 | -|------|------|------| -| id | UUID | 报告 ID | -| type | string | 类型:`daily` / `weekly` | -| status | string | 状态 | -| period.start | ISO8601 | 周期开始 | -| period.end | ISO8601 | 周期结束 | -| platform_filter | string | 平台筛选 | -| item_count | number | 内容数量 | -| summary_text | string \| null | 生成的摘要(Markdown 格式) | -| highlights | array \| null | 结构化高亮内容 | -| llm_model | string | 使用的 LLM 模型 | -| llm_tokens_input | number \| null | 输入 token 数 | -| llm_tokens_output | number \| null | 输出 token 数 | -| triggered_by | string | 触发方式 | -| created_at | ISO8601 | 创建时间 | -| updated_at | ISO8601 | 更新时间 | - -#### 错误响应 (404) - -```json -{ - "code": "summary_not_found", - "message": "Summary with id xxx not found" -} -``` - ---- - -### 删除报告 - -**DELETE** `/api/summaries/:id` - -删除一个报告。 - -#### 路径参数 - -| 参数 | 类型 | 说明 | -|------|------|------| -| id | UUID | 报告 ID | - -#### 成功响应 (200) - -```json -{ - "message": "Summary deleted successfully" -} -``` - -#### 错误响应 (404) - -```json -{ - "code": "summary_not_found", - "message": "Summary with id xxx not found" -} -``` - ---- - -### 重新生成报告 - -**POST** `/api/summaries/:id/regenerate` - -重新生成一个已存在的报告。 - -#### 路径参数 - -| 参数 | 类型 | 说明 | -|------|------|------| -| id | UUID | 报告 ID | - -#### 成功响应 (200) - -```json -{ - "id": "summary-uuid", - "status": "processing", - "message": "Summary regeneration triggered" -} -``` - -#### 错误响应 (404) - -```json -{ - "code": "summary_not_found", - "message": "Summary with id xxx not found" -} -``` - ---- - -### 报告统计 - -**GET** `/api/summaries/stats` - -获取报告统计信息。 - -#### 成功响应 (200) - -```json -{ - "total": 50, - "by_type": { - "daily": 40, - "weekly": 10 - }, - "by_status": { - "completed": 48, - "failed": 2, - "processing": 0 - }, - "tokens_used": { - "total_input": 200000, - "total_output": 50000 - }, - "last_daily": { - "id": "summary-uuid", - "period_start": "2024-01-01T00:00:00.000Z", - "created_at": "2024-01-02T10:00:00.000Z" - }, - "last_weekly": { - "id": "summary-uuid", - "period_start": "2023-12-25T00:00:00.000Z", - "created_at": "2024-01-01T10:00:00.000Z" - } -} -``` - -| 字段 | 类型 | 说明 | -|------|------|------| -| total | number | 报告总数 | -| by_type.daily | number | 日报数量 | -| by_type.weekly | number | 周报数量 | -| by_status.completed | number | 已完成数量 | -| by_status.failed | number | 失败数量 | -| by_status.processing | number | 处理中数量 | -| tokens_used.total_input | number | 总输入 token 数 | -| tokens_used.total_output | number | 总输出 token 数 | -| last_daily | object \| null | 最近的日报信息 | -| last_weekly | object \| null | 最近的周报信息 | - ---- - -## 其他端点 - -### 触发 RSS 抓取 - -**POST** `/api/fetch` - -手动触发一次 RSS 抓取(后台运行)。 - -#### 成功响应 (200) - -```json -{ - "success": true, - "message": "Fetch started in background" -} -``` - ---- - -### 触发内容处理 - -**POST** `/api/feed-items/process` - -手动触发内容处理(获取完整内容、生成摘要)。**立即返回,后台异步处理。** - -#### 查询参数 - -| 参数 | 类型 | 说明 | -|------|------|------| -| platform | string | 平台筛选:`youtube` 或 `twitter`(可选) | -| limit | number | 处理数量上限(可选) | - -#### 成功响应 (200) - -```json -{ - "message": "Content processing started in background", - "status": "started" -} -``` - -> 处理结果会输出到服务端日志。 - ---- - -## 错误响应格式 - -所有错误响应遵循统一格式: - -```json -{ - "code": "error_code", - "message": "Human readable error message", - "platform": "youtube", - "url_help": "Detailed help message (optional)", - "errors": [] -} -``` - -### 常见错误码 - -| 错误码 | HTTP 状态 | 说明 | -|--------|----------|------| -| `validation_error` | 400 | 请求参数校验失败 | -| `invalid_url` | 400 | 不支持的 URL 格式 | -| `invalid_id` | 400 | 无效的 ID 格式(UUID 格式错误) | -| `unsupported_platform` | 400 | 不支持的平台 | -| `not_found` | 404 | Feed 资源不存在 | -| `summary_not_found` | 404 | 报告不存在 | -| `fetch_error` | 500 | 数据获取错误 | -| `delete_error` | 500 | 删除操作错误 | -| `regenerate_error` | 500 | 重新生成错误 | -| `stats_error` | 500 | 统计信息获取错误 | -| `internal_error` | 500 | 服务器内部错误 | diff --git a/env.example b/env.example index b447af5..4f69d53 100644 --- a/env.example +++ b/env.example @@ -24,10 +24,6 @@ FILE_PREVIEW_BASE_URL=http://localhost:3000 # - Docker 部署: http://markdown-to-pdf:3000 PDF_SERVICE_URL=http://markdown-to-pdf:3000 -# Subscription Agent 服务配置 -SUBSCRIPTION_AGENT_API_URL=https://subscription-agent.example.com -SUBSCRIPTION_AGENT_API_KEY=your-api-key - # 用户注册邀请码(用于公开注册功能) REGISTRATION_INVITE_CODE=your-invite-code-here diff --git a/lib/appTrigger.ts b/lib/appTrigger.ts index 1d51367..3e6171c 100644 --- a/lib/appTrigger.ts +++ b/lib/appTrigger.ts @@ -1,27 +1,9 @@ -/** - * 触发方式(列名沿用 app_type)的显示名。 - * - * **不要直接渲染 app_type 的原始值**:底层存的 `Subscription` 在界面上叫「定时任务」, - * 两者故意不一致——前端 isStreamApp 靠这个值决定要不要显示订阅源管理,换成新值就得 - * 迁移存量数据并改 8 处调用,而收益只是名字好看。 - * - * 放在 lib 而不是某个页面里:列表页和详情页都要用,两处各写一份迟早分叉(详情页 - * 一度就还在显示原始的 "Chat")。 - * - * Tool / Plugin 不再是可选项,但存量数据里可能还有,仍给出各自的旧文案, - * 不至于显示成空白。 - */ -export const TRIGGER_LABEL_KEY: Readonly> = { +export const PRESENTATION_MODE_LABEL_KEY: Readonly> = { Chat: "chatType", - Subscription: "subscriptionType", - Email: "emailType", Custom: "customType", - Tool: "toolType", - Plugin: "pluginType", }; -/** 认不出的值原样显示——比显示空白强,也让"哪儿冒出个新值"看得见 */ -export function triggerLabel(appType: string, t: (key: never) => string): string { - const key = TRIGGER_LABEL_KEY[appType]; - return key ? t(key as never) : appType; +export function presentationModeLabel(appType: string, t: (key: never) => string): string { + const key = PRESENTATION_MODE_LABEL_KEY[appType]; + return key ? t(key as never) : ""; } diff --git a/lib/cron/index.ts b/lib/cron/index.ts index 8734593..0b3afc4 100644 --- a/lib/cron/index.ts +++ b/lib/cron/index.ts @@ -1,19 +1,8 @@ -import { initScheduler } from "./subscription-scheduler"; import { initAutomationScheduler } from "./automation-scheduler"; // Only run on server-side when ENABLE_CRON is true if (typeof window === "undefined" && process.env.ENABLE_CRON === "true") { - Promise.all([ - initScheduler(), - initAutomationScheduler(), - ]).catch(console.error); + initAutomationScheduler().catch(console.error); } -export { - initScheduler, - scheduleApp, - unscheduleApp, - syncAppSchedule, -} from "./subscription-scheduler"; - export { initAutomationScheduler } from "./automation-scheduler"; diff --git a/lib/cron/subscription-scheduler.ts b/lib/cron/subscription-scheduler.ts deleted file mode 100644 index 44888d7..0000000 --- a/lib/cron/subscription-scheduler.ts +++ /dev/null @@ -1,144 +0,0 @@ -import cron, { type ScheduledTask } from "node-cron"; -import pool from "@/lib/db"; -import { - generateDailySummary, - generateWeeklySummary, -} from "@/lib/subscription-agent/generate-summary"; - -// Store all active cron tasks: appId -> ScheduledTask -const activeTasks = new Map(); - -/** - * Initialize: Load all enabled scheduled Subscription apps from database and create cron tasks - */ -export async function initScheduler() { - try { - const result = await pool.query(` - SELECT id, name, settings - FROM apps - WHERE app_type = 'Subscription' - AND settings->'schedule'->>'enabled' = 'true' - `); - - for (const app of result.rows) { - scheduleApp(app.id, app.settings); - } - console.log(`[Cron] Initialized ${result.rows.length} scheduled tasks`); - } catch (error) { - console.error("[Cron] Failed to initialize scheduler:", error); - } -} - -/** - * Create cron task for specified app - */ -export function scheduleApp(appId: number, settings: any) { - // Remove old task first - unscheduleApp(appId); - - const { schedule } = settings; - if (!schedule?.enabled || !schedule?.time) return; - - const [hour, minute] = schedule.time.split(":"); - const cronExpression = `${minute} ${hour} * * *`; // Execute at specified time every day - - const task = cron.schedule( - cronExpression, - async () => { - await executeReport(appId, settings); - }, - { - timezone: schedule.timezone || "Asia/Shanghai", - } - ); - - activeTasks.set(appId, task); - console.log( - `[Cron] Scheduled app ${appId} at ${schedule.time} (${schedule.timezone || "Asia/Shanghai"})` - ); -} - -/** - * Remove cron task for specified app - */ -export function unscheduleApp(appId: number) { - const task = activeTasks.get(appId); - if (task) { - task.stop(); - activeTasks.delete(appId); - console.log(`[Cron] Unscheduled app ${appId}`); - } -} - -/** - * Execute report generation (directly call function, no HTTP) - */ -async function executeReport(appId: number, settings: any) { - const { stream_feed_ids, topic, webhook_url, schedule } = settings; - const today = new Date().toISOString().split("T")[0]; - - try { - // Check if already executed today (prevent duplicates) - const check = await pool.query( - `SELECT settings->'schedule'->>'last_run_date' as last_run FROM apps WHERE id = $1`, - [appId] - ); - if (check.rows[0]?.last_run === today) { - console.log(`[Cron] App ${appId} already ran today, skipping`); - return; - } - - // Directly call the encapsulated function - const params = { feedIds: stream_feed_ids, topic, webhook_url }; - if (schedule.report_type === "weekly") { - await generateWeeklySummary(params); - } else { - await generateDailySummary(params); - } - - // Update execution status - await pool.query( - ` - UPDATE apps - SET settings = jsonb_set( - jsonb_set(settings, '{schedule,last_run_date}', $1::jsonb), - '{schedule,last_run_status}', $2::jsonb - ) - WHERE id = $3 - `, - [JSON.stringify(today), JSON.stringify("success"), appId] - ); - - console.log(`[Cron] App ${appId} report generated: success`); - } catch (error) { - // Update failure status - await pool.query( - ` - UPDATE apps - SET settings = jsonb_set( - jsonb_set(settings, '{schedule,last_run_date}', $1::jsonb), - '{schedule,last_run_status}', $2::jsonb - ) - WHERE id = $3 - `, - [JSON.stringify(today), JSON.stringify("failed"), appId] - ); - console.error(`[Cron] App ${appId} failed:`, error); - } -} - -/** - * Sync cron task when app settings change - * Call this after app update to sync the cron task - */ -export function syncAppSchedule(appId: number, appType: string, settings: any) { - if (appType !== "Subscription") { - return; - } - - if (settings?.schedule?.enabled) { - scheduleApp(appId, settings); - } else { - unscheduleApp(appId); - } -} diff --git a/lib/subscription-agent/generate-summary.ts b/lib/subscription-agent/generate-summary.ts deleted file mode 100644 index 226c3e3..0000000 --- a/lib/subscription-agent/generate-summary.ts +++ /dev/null @@ -1,58 +0,0 @@ -const SUBSCRIPTION_AGENT_API_URL = process.env.SUBSCRIPTION_AGENT_API_URL; -const SUBSCRIPTION_AGENT_API_KEY = process.env.SUBSCRIPTION_AGENT_API_KEY; - -export interface GenerateSummaryParams { - feedIds?: string[]; - topic?: string; - webhook_url?: string; -} - -/** - * Generate daily summary (calls external Subscription Agent service) - */ -export async function generateDailySummary(params: GenerateSummaryParams) { - if (!SUBSCRIPTION_AGENT_API_URL || !SUBSCRIPTION_AGENT_API_KEY) { - throw new Error("Subscription Agent service is not configured"); - } - - const response = await fetch(`${SUBSCRIPTION_AGENT_API_URL}/api/summaries/generate/today`, { - method: "POST", - headers: { - Authorization: `Bearer ${SUBSCRIPTION_AGENT_API_KEY}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(params), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(error); - } - - return response.json(); -} - -/** - * Generate weekly summary (calls external Subscription Agent service) - */ -export async function generateWeeklySummary(params: GenerateSummaryParams) { - if (!SUBSCRIPTION_AGENT_API_URL || !SUBSCRIPTION_AGENT_API_KEY) { - throw new Error("Subscription Agent service is not configured"); - } - - const response = await fetch(`${SUBSCRIPTION_AGENT_API_URL}/api/summaries/generate/week`, { - method: "POST", - headers: { - Authorization: `Bearer ${SUBSCRIPTION_AGENT_API_KEY}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(params), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(error); - } - - return response.json(); -} diff --git a/messages/en/apps.json b/messages/en/apps.json index f64cb3c..0da3763 100644 --- a/messages/en/apps.json +++ b/messages/en/apps.json @@ -15,7 +15,7 @@ "appNamePlaceholder": "Enter digital employee name", "appDesc": "Description", "appDescPlaceholder": "Enter digital employee description", - "appType": "Trigger", + "appType": "Presentation mode", "platform": "Platform", "aiModel": "AI Model", "author": "Author", @@ -46,10 +46,7 @@ "workflowConfig": "Workflow Config", "chatType": "Chat", "toolType": "Tool", - "subscriptionType": "Scheduled", - "pluginType": "Plugin", "customType": "Custom", - "emailType": "Email", "pluginPlatform": "Plugin", "customView": "Custom View", "customViewPlaceholder": "Select a view to render", @@ -68,17 +65,6 @@ "mpnewsMessage": "Rich Media Message", "markdownMessage": "Markdown Message", "fileMessage": "File Message", - "subscriptionSource": "Subscription Sources", - "subscriptionUrlPlaceholder": "Enter YouTube channel or Twitter user URL", - "subscriptionUrlHelp": "Supported formats: https://www.youtube.com/channel/UCxxxx or https://twitter.com/username", - "subscriptionAdded": "Subscription added: {name}", - "addSubscriptionFailed": "Failed to add subscription", - "topicFilter": "Topic Filter", - "topicFilterPlaceholder": "e.g., AI, Technology, Finance", - "topicFilterHelp": "Only include content related to this topic in generated reports", - "webhookUrl": "Webhook URL", - "webhookUrlPlaceholder": "https://example.com/webhook", - "webhookUrlHelp": "Generated reports will be pushed to this URL as Markdown", "selectTemplate": "Select Digital Employee Template", "selectTemplateDesc": "Choose a preset template to quickly create a digital employee", "qualityAgent": "Quality Classification Agent", @@ -104,7 +90,7 @@ "creating": "Creating...", "operationFailed": "Operation failed", "tableHeaderAppName": "Digital Employee", - "tableHeaderType": "Type", + "tableHeaderType": "Presentation mode", "tableHeaderPlatform": "Platform", "tableHeaderAiModel": "AI Model", "tableHeaderDatasets": "Datasets", @@ -177,14 +163,6 @@ "allToolsBound": "All available tools have been bound", "bind": "Bind", "bindCount": "Bind ({count})", - "scheduleConfig": "Schedule Configuration", - "enableSchedule": "Enable Scheduled Report", - "enableScheduleDesc": "Automatically generate reports at the specified time", - "scheduleTime": "Schedule Time", - "reportType": "Report Type", - "dailyReport": "Daily Report", - "weeklyReport": "Weekly Report", - "timezone": "Timezone", "autoSelectDatasets": "Auto-select knowledge bases", "tableHeaderSkills": "Skills", "avatar": "Avatar", diff --git a/messages/zh-CN/apps.json b/messages/zh-CN/apps.json index 13fb151..a325ada 100644 --- a/messages/zh-CN/apps.json +++ b/messages/zh-CN/apps.json @@ -15,7 +15,7 @@ "appNamePlaceholder": "输入数字员工名称", "appDesc": "描述", "appDescPlaceholder": "输入数字员工描述", - "appType": "触发方式", + "appType": "展示方式", "platform": "平台", "aiModel": "AI 模型", "author": "作者", @@ -46,10 +46,7 @@ "workflowConfig": "工作流配置", "chatType": "聊天", "toolType": "Tool - 工具类", - "subscriptionType": "定时任务", - "pluginType": "Plugin - 插件类", "customType": "自定义", - "emailType": "邮件", "pluginPlatform": "插件", "customView": "自定义视图", "customViewPlaceholder": "选择要渲染的视图", @@ -68,17 +65,6 @@ "mpnewsMessage": "图文消息", "markdownMessage": "Markdown消息", "fileMessage": "文件消息", - "subscriptionSource": "订阅源", - "subscriptionUrlPlaceholder": "输入 YouTube 频道或 Twitter 用户 URL", - "subscriptionUrlHelp": "支持格式:https://www.youtube.com/channel/UCxxxx 或 https://twitter.com/username", - "subscriptionAdded": "已添加订阅: {name}", - "addSubscriptionFailed": "添加订阅失败", - "topicFilter": "主题过滤", - "topicFilterPlaceholder": "例如:AI、科技、金融", - "topicFilterHelp": "仅在生成的报告中包含与此主题相关的内容", - "webhookUrl": "Webhook 推送地址", - "webhookUrlPlaceholder": "https://example.com/webhook", - "webhookUrlHelp": "生成的报告将以 Markdown 格式推送到此地址", "selectTemplate": "选择数字员工模板", "selectTemplateDesc": "选择一个预置模板快速创建数字员工", "qualityAgent": "质量分类智能体", @@ -103,10 +89,10 @@ "add": "添加", "creating": "创建中...", "operationFailed": "操作失败", - "tableHeaderAppName": "数字员工名称", - "tableHeaderType": "类型", + "tableHeaderAppName": "数字员工", + "tableHeaderType": "展示方式", "tableHeaderPlatform": "平台", - "tableHeaderAiModel": "AI模型", + "tableHeaderAiModel": "AI 模型", "tableHeaderDatasets": "数据集", "tableHeaderTools": "工具", "tableHeaderCreatedTime": "创建时间", @@ -177,14 +163,6 @@ "allToolsBound": "所有可用工具都已绑定", "bind": "绑定", "bindCount": "绑定 ({count})", - "scheduleConfig": "定时任务配置", - "enableSchedule": "启用定时报告", - "enableScheduleDesc": "在指定时间自动生成报告", - "scheduleTime": "执行时间", - "reportType": "报告类型", - "dailyReport": "日报", - "weeklyReport": "周报", - "timezone": "时区", "autoSelectDatasets": "自动选择知识库", "tableHeaderSkills": "技能", "avatar": "头像", diff --git a/pages/api/v1/apps/[id].ts b/pages/api/v1/apps/[id].ts index 4e7dc77..d219af6 100644 --- a/pages/api/v1/apps/[id].ts +++ b/pages/api/v1/apps/[id].ts @@ -1,7 +1,6 @@ import { NextApiRequest, NextApiResponse } from "next"; import axios from "axios"; import { getUserIdFromRequest } from "@/lib/auth"; -import { syncAppSchedule, unscheduleApp } from "@/lib/cron/subscription-scheduler"; const EXTERNAL_API_BASE_URL = process.env.EXTERNAL_API_BASE_URL || "http://localhost:8000"; @@ -86,12 +85,6 @@ async function handlePut(req: NextApiRequest, res: NextApiResponse) { }, }); - // Sync cron task for Subscription apps - const appData = response.data; - if (appData.app_type === "Subscription") { - syncAppSchedule(Number(id), appData.app_type, appData.settings); - } - return res.status(200).json(response.data); } catch (error: any) { console.error("Error updating app:", error); @@ -133,9 +126,6 @@ async function handleDelete(req: NextApiRequest, res: NextApiResponse) { }, }); - // Remove cron task if exists - unscheduleApp(Number(id)); - return res.status(204).end(); } catch (error: any) { console.error("Error deleting app:", error); diff --git a/pages/api/v1/subscription-agent/feeds/[id].ts b/pages/api/v1/subscription-agent/feeds/[id].ts deleted file mode 100644 index fa581eb..0000000 --- a/pages/api/v1/subscription-agent/feeds/[id].ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import { requireAuth } from "@/lib/auth"; - -const SUBSCRIPTION_AGENT_API_URL = process.env.SUBSCRIPTION_AGENT_API_URL; -const SUBSCRIPTION_AGENT_API_KEY = process.env.SUBSCRIPTION_AGENT_API_KEY; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const authResult = await requireAuth(req, res); - if (!authResult) return; - - if (!SUBSCRIPTION_AGENT_API_URL || !SUBSCRIPTION_AGENT_API_KEY) { - return res.status(500).json({ message: "Subscription Agent 服务未配置" }); - } - - const { id } = req.query; - - if (!id || typeof id !== "string") { - return res.status(400).json({ message: "订阅 ID 不能为空" }); - } - - try { - if (req.method === "GET") { - // GET /api/v1/stream-agent/feeds/:id - 获取订阅详情 - const response = await fetch(`${SUBSCRIPTION_AGENT_API_URL}/api/feeds/${id}`, { - method: "GET", - headers: { - Authorization: `Bearer ${SUBSCRIPTION_AGENT_API_KEY}`, - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - const error = await response.text(); - return res.status(response.status).json({ message: error }); - } - - const data = await response.json(); - return res.status(200).json(data); - } else if (req.method === "DELETE") { - // DELETE /api/v1/stream-agent/feeds/:id - 删除订阅 - const response = await fetch(`${SUBSCRIPTION_AGENT_API_URL}/api/feeds/${id}`, { - method: "DELETE", - headers: { - Authorization: `Bearer ${SUBSCRIPTION_AGENT_API_KEY}`, - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - const error = await response.text(); - return res.status(response.status).json({ message: error }); - } - - return res.status(204).end(); - } else { - res.setHeader("Allow", ["GET", "DELETE"]); - return res.status(405).json({ message: `不支持的请求方法: ${req.method}` }); - } - } catch (error: any) { - console.error("Subscription Agent API error:", error); - return res.status(500).json({ message: error.message || "服务请求失败" }); - } -} diff --git a/pages/api/v1/subscription-agent/feeds/[id]/items.ts b/pages/api/v1/subscription-agent/feeds/[id]/items.ts deleted file mode 100644 index 5e02dff..0000000 --- a/pages/api/v1/subscription-agent/feeds/[id]/items.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import { requireAuth } from "@/lib/auth"; - -const SUBSCRIPTION_AGENT_API_URL = process.env.SUBSCRIPTION_AGENT_API_URL; -const SUBSCRIPTION_AGENT_API_KEY = process.env.SUBSCRIPTION_AGENT_API_KEY; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const authResult = await requireAuth(req, res); - if (!authResult) return; - - if (!SUBSCRIPTION_AGENT_API_URL || !SUBSCRIPTION_AGENT_API_KEY) { - return res.status(500).json({ message: "Subscription Agent 服务未配置" }); - } - - const { id, page = "1", pageSize = "50" } = req.query; - - if (!id || typeof id !== "string") { - return res.status(400).json({ message: "订阅 ID 不能为空" }); - } - - if (req.method !== "GET") { - res.setHeader("Allow", ["GET"]); - return res.status(405).json({ message: `不支持的请求方法: ${req.method}` }); - } - - try { - // GET /api/v1/stream-agent/feeds/:id/items - 获取订阅内容列表 - const queryParams = new URLSearchParams({ - page: String(page), - pageSize: String(pageSize), - }); - - const response = await fetch( - `${SUBSCRIPTION_AGENT_API_URL}/api/feeds/${id}/items?${queryParams}`, - { - method: "GET", - headers: { - Authorization: `Bearer ${SUBSCRIPTION_AGENT_API_KEY}`, - "Content-Type": "application/json", - }, - } - ); - - if (!response.ok) { - const error = await response.text(); - return res.status(response.status).json({ message: error }); - } - - const data = await response.json(); - return res.status(200).json(data); - } catch (error: any) { - console.error("Subscription Agent API error:", error); - return res.status(500).json({ message: error.message || "服务请求失败" }); - } -} diff --git a/pages/api/v1/subscription-agent/feeds/index.ts b/pages/api/v1/subscription-agent/feeds/index.ts deleted file mode 100644 index 30cc48b..0000000 --- a/pages/api/v1/subscription-agent/feeds/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import { requireAuth } from "@/lib/auth"; - -const SUBSCRIPTION_AGENT_API_URL = process.env.SUBSCRIPTION_AGENT_API_URL; -const SUBSCRIPTION_AGENT_API_KEY = process.env.SUBSCRIPTION_AGENT_API_KEY; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const authResult = await requireAuth(req, res); - if (!authResult) return; - - if (!SUBSCRIPTION_AGENT_API_URL || !SUBSCRIPTION_AGENT_API_KEY) { - return res.status(500).json({ message: "Subscription Agent 服务未配置" }); - } - - try { - if (req.method === "GET") { - // GET /api/v1/stream-agent/feeds - 获取订阅列表 - const response = await fetch(`${SUBSCRIPTION_AGENT_API_URL}/api/feeds`, { - method: "GET", - headers: { - Authorization: `Bearer ${SUBSCRIPTION_AGENT_API_KEY}`, - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - const error = await response.text(); - return res.status(response.status).json({ message: error }); - } - - const data = await response.json(); - return res.status(200).json(data); - } else if (req.method === "POST") { - // POST /api/v1/stream-agent/feeds - 创建订阅 - const { url } = req.body; - - if (!url) { - return res.status(400).json({ message: "订阅 URL 不能为空" }); - } - - const response = await fetch(`${SUBSCRIPTION_AGENT_API_URL}/api/feeds`, { - method: "POST", - headers: { - Authorization: `Bearer ${SUBSCRIPTION_AGENT_API_KEY}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ url }), - }); - - if (!response.ok) { - const error = await response.text(); - return res.status(response.status).json({ message: error }); - } - - const data = await response.json(); - return res.status(201).json(data); - } else { - res.setHeader("Allow", ["GET", "POST"]); - return res.status(405).json({ message: `不支持的请求方法: ${req.method}` }); - } - } catch (error: any) { - console.error("Subscription Agent API error:", error); - return res.status(500).json({ message: error.message || "服务请求失败" }); - } -} diff --git a/pages/api/v1/subscription-agent/fetch.ts b/pages/api/v1/subscription-agent/fetch.ts deleted file mode 100644 index 84aa150..0000000 --- a/pages/api/v1/subscription-agent/fetch.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import { requireAuth } from "@/lib/auth"; - -const SUBSCRIPTION_AGENT_API_URL = process.env.SUBSCRIPTION_AGENT_API_URL; -const SUBSCRIPTION_AGENT_API_KEY = process.env.SUBSCRIPTION_AGENT_API_KEY; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const authResult = await requireAuth(req, res); - if (!authResult) return; - - if (!SUBSCRIPTION_AGENT_API_URL || !SUBSCRIPTION_AGENT_API_KEY) { - return res.status(500).json({ message: "Subscription Agent 服务未配置" }); - } - - if (req.method !== "POST") { - res.setHeader("Allow", ["POST"]); - return res.status(405).json({ message: `不支持的请求方法: ${req.method}` }); - } - - try { - const { feedIds } = req.body; - - // POST /api/v1/stream-agent/fetch - 触发 RSS 抓取 - const response = await fetch(`${SUBSCRIPTION_AGENT_API_URL}/api/fetch`, { - method: "POST", - headers: { - Authorization: `Bearer ${SUBSCRIPTION_AGENT_API_KEY}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ feedIds }), - }); - - if (!response.ok) { - const error = await response.text(); - return res.status(response.status).json({ message: error }); - } - - const data = await response.json(); - return res.status(200).json(data); - } catch (error: any) { - console.error("Subscription Agent API error:", error); - return res.status(500).json({ message: error.message || "服务请求失败" }); - } -} diff --git a/pages/api/v1/subscription-agent/summaries/[id].ts b/pages/api/v1/subscription-agent/summaries/[id].ts deleted file mode 100644 index d56c616..0000000 --- a/pages/api/v1/subscription-agent/summaries/[id].ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import { requireAuth } from "@/lib/auth"; - -const SUBSCRIPTION_AGENT_API_URL = process.env.SUBSCRIPTION_AGENT_API_URL; -const SUBSCRIPTION_AGENT_API_KEY = process.env.SUBSCRIPTION_AGENT_API_KEY; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const authResult = await requireAuth(req, res); - if (!authResult) return; - - if (!SUBSCRIPTION_AGENT_API_URL || !SUBSCRIPTION_AGENT_API_KEY) { - return res.status(500).json({ message: "Subscription Agent 服务未配置" }); - } - - const { id } = req.query; - - if (!id || typeof id !== "string") { - return res.status(400).json({ message: "报告 ID 不能为空" }); - } - - if (req.method !== "GET") { - res.setHeader("Allow", ["GET"]); - return res.status(405).json({ message: `不支持的请求方法: ${req.method}` }); - } - - try { - // GET /api/v1/stream-agent/summaries/:id - 获取报告详情 - const response = await fetch(`${SUBSCRIPTION_AGENT_API_URL}/api/summaries/${id}`, { - method: "GET", - headers: { - Authorization: `Bearer ${SUBSCRIPTION_AGENT_API_KEY}`, - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - const error = await response.text(); - return res.status(response.status).json({ message: error }); - } - - const data = await response.json(); - return res.status(200).json(data); - } catch (error: any) { - console.error("Subscription Agent API error:", error); - return res.status(500).json({ message: error.message || "服务请求失败" }); - } -} diff --git a/pages/api/v1/subscription-agent/summaries/generate/today.ts b/pages/api/v1/subscription-agent/summaries/generate/today.ts deleted file mode 100644 index 1a0ec5c..0000000 --- a/pages/api/v1/subscription-agent/summaries/generate/today.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import { requireAuth } from "@/lib/auth"; -import { generateDailySummary } from "@/lib/subscription-agent/generate-summary"; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const authResult = await requireAuth(req, res); - if (!authResult) return; - - if (req.method !== "POST") { - res.setHeader("Allow", ["POST"]); - return res.status(405).json({ message: `不支持的请求方法: ${req.method}` }); - } - - try { - const { feedIds, topic, webhook_url } = req.body; - const data = await generateDailySummary({ feedIds, topic, webhook_url }); - return res.status(200).json(data); - } catch (error: any) { - console.error("Subscription Agent API error:", error); - return res.status(500).json({ message: error.message || "服务请求失败" }); - } -} diff --git a/pages/api/v1/subscription-agent/summaries/generate/week.ts b/pages/api/v1/subscription-agent/summaries/generate/week.ts deleted file mode 100644 index e2702d0..0000000 --- a/pages/api/v1/subscription-agent/summaries/generate/week.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import { requireAuth } from "@/lib/auth"; -import { generateWeeklySummary } from "@/lib/subscription-agent/generate-summary"; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const authResult = await requireAuth(req, res); - if (!authResult) return; - - if (req.method !== "POST") { - res.setHeader("Allow", ["POST"]); - return res.status(405).json({ message: `不支持的请求方法: ${req.method}` }); - } - - try { - const { feedIds, topic, webhook_url } = req.body; - const data = await generateWeeklySummary({ feedIds, topic, webhook_url }); - return res.status(200).json(data); - } catch (error: any) { - console.error("Subscription Agent API error:", error); - return res.status(500).json({ message: error.message || "服务请求失败" }); - } -} diff --git a/pages/api/v1/subscription-agent/summaries/index.ts b/pages/api/v1/subscription-agent/summaries/index.ts deleted file mode 100644 index b032db0..0000000 --- a/pages/api/v1/subscription-agent/summaries/index.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import { requireAuth } from "@/lib/auth"; - -const SUBSCRIPTION_AGENT_API_URL = process.env.SUBSCRIPTION_AGENT_API_URL; -const SUBSCRIPTION_AGENT_API_KEY = process.env.SUBSCRIPTION_AGENT_API_KEY; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const authResult = await requireAuth(req, res); - if (!authResult) return; - - if (!SUBSCRIPTION_AGENT_API_URL || !SUBSCRIPTION_AGENT_API_KEY) { - return res.status(500).json({ message: "Subscription Agent 服务未配置" }); - } - - if (req.method !== "GET") { - res.setHeader("Allow", ["GET"]); - return res.status(405).json({ message: `不支持的请求方法: ${req.method}` }); - } - - try { - const { page = "1", pageSize = "20", type, status, feedIds } = req.query; - - // GET /api/v1/stream-agent/summaries - 获取报告列表 - const queryParams = new URLSearchParams({ - page: String(page), - pageSize: String(pageSize), - }); - - if (type) { - queryParams.append("type", String(type)); - } - - if (status) { - queryParams.append("status", String(status)); - } - - if (feedIds) { - // feedIds 可以是数组或逗号分隔的字符串 - const feedIdArray = Array.isArray(feedIds) ? feedIds : String(feedIds).split(","); - feedIdArray.forEach((id) => queryParams.append("feedIds", id)); - } - - const response = await fetch(`${SUBSCRIPTION_AGENT_API_URL}/api/summaries?${queryParams}`, { - method: "GET", - headers: { - Authorization: `Bearer ${SUBSCRIPTION_AGENT_API_KEY}`, - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - const error = await response.text(); - return res.status(response.status).json({ message: error }); - } - - const data = await response.json(); - return res.status(200).json(data); - } catch (error: any) { - console.error("Subscription Agent API error:", error); - return res.status(500).json({ message: error.message || "服务请求失败" }); - } -} diff --git a/test/appPresentationModeCleanup.test.ts b/test/appPresentationModeCleanup.test.ts new file mode 100644 index 0000000..005e81c --- /dev/null +++ b/test/appPresentationModeCleanup.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; + +const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); + +test("App 页面与代理不再接入 App 专属订阅链路", () => { + for (const path of ["app/apps/page.tsx", "pages/api/v1/apps/[id].ts"]) { + assert.doesNotMatch(read(path), /subscription-agent|subscription-scheduler/); + } +}); + +test("cron 仅保留自动化调度器", () => { + const cronIndex = read("lib/cron/index.ts"); + assert.match(cronIndex, /initAutomationScheduler/); + assert.doesNotMatch(cronIndex, /subscription-scheduler|initScheduler|scheduleApp|unscheduleApp/); +}); + +test("自动化定时和邮件能力仍然存在", () => { + assert.match(read("lib/cron/automation-scheduler.ts"), /initAutomationScheduler/); + assert.match(read("lib/automation/store.ts"), /email/); +}); diff --git a/test/appTrigger.test.ts b/test/appTrigger.test.ts index 3cb6eec..71b01d4 100644 --- a/test/appTrigger.test.ts +++ b/test/appTrigger.test.ts @@ -1,30 +1,23 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { TRIGGER_LABEL_KEY, triggerLabel } from "../lib/appTrigger.ts"; +import { PRESENTATION_MODE_LABEL_KEY, presentationModeLabel } from "../lib/appTrigger.ts"; // 假 t:直接回文案键,便于断言映射本身 const t = ((k: string) => k) as unknown as (key: never) => string; -test("底层值 → 文案键的映射", () => { - assert.equal(triggerLabel("Chat", t), "chatType"); - assert.equal(triggerLabel("Email", t), "emailType"); - assert.equal(triggerLabel("Custom", t), "customType"); +test("展示方式仅映射聊天和自定义", () => { + assert.equal(presentationModeLabel("Chat", t), "chatType"); + assert.equal(presentationModeLabel("Custom", t), "customType"); + assert.deepEqual(PRESENTATION_MODE_LABEL_KEY, { + Chat: "chatType", + Custom: "customType", + }); }); -test("Subscription 显示为定时任务,但值本身不变", () => { - // 这条是整个改动的关键取舍:显示归到「定时任务」,存的值仍是 Subscription, - // 因为 isStreamApp 靠它决定要不要显示订阅源管理。 - assert.equal(triggerLabel("Subscription", t), "subscriptionType"); - assert.ok("Subscription" in TRIGGER_LABEL_KEY); -}); - -test("撤下的 Tool / Plugin 仍有文案,不显示空白", () => { - assert.equal(triggerLabel("Tool", t), "toolType"); - assert.equal(triggerLabel("Plugin", t), "pluginType"); -}); - -test("认不出的值原样显示", () => { - // 比显示空白强,也让"哪儿冒出个新值"看得见 - assert.equal(triggerLabel("SomethingNew", t), "SomethingNew"); - assert.equal(triggerLabel("", t), ""); +test("旧展示方式不会被渲染成有效选项", () => { + assert.equal(presentationModeLabel("Subscription", t), ""); + assert.equal(presentationModeLabel("Email", t), ""); + assert.equal(presentationModeLabel("Tool", t), ""); + assert.equal(presentationModeLabel("Plugin", t), ""); + assert.equal(presentationModeLabel("SomethingNew", t), ""); }); diff --git a/types/subscription-agent.ts b/types/subscription-agent.ts deleted file mode 100644 index 5ce35fd..0000000 --- a/types/subscription-agent.ts +++ /dev/null @@ -1,110 +0,0 @@ -export interface Feed { - id: string; - platform: "youtube" | "twitter"; - source_url: string; - rsshub_route: string; - status: "active" | "inactive" | "error"; - subscribed_at: string; - last_fetch_at: string | null; - last_fetch_status: "success" | "fail" | null; -} - -export interface FeedItem { - id: string; - title: string; - link: string; - summary: string | null; - author: string | null; - published_at: string; - fetched_at: string; -} - -export interface Summary { - id: string; - type: "daily" | "weekly"; - status: "pending" | "processing" | "completed" | "failed"; - period_start: string; - period_end: string; - platform_filter: "all" | "youtube" | "twitter"; - item_count: number; - summary_text?: string; - highlights?: Array<{ - platform: string; - title: string; - link: string; - summary: string; - }>; - llm_model?: string; - llm_tokens_input?: number; - llm_tokens_output?: number; - triggered_by: "scheduled" | "manual"; - created_at: string; - updated_at?: string; -} - -export interface StreamFeedFormItem { - id: string; - url: string; - name: string; - platform: "youtube" | "twitter"; -} - -export interface FeedItemsResponse { - items: FeedItem[]; - pagination: { - page: number; - pageSize: number; - total: number; - }; - query?: { - from: string | null; - to: string | null; - }; - warning?: { - code: string; - message: string; - subscribed_at: string; - incomplete_range: { - from: string; - to: string; - }; - }; -} - -export interface SummaryListResponse { - data: Summary[]; - pagination: { - page: number; - pageSize: number; - total: number; - }; -} - -export interface GenerateSummaryRequest { - feedIds?: string[]; - topic?: string; - webhook_url?: string; -} - -export interface GenerateSummaryResponse { - type: "daily" | "weekly"; - status: "pending"; - summaryId: string; - feedIds: string[] | null; - topic: string | null; - webhookUrl: string | null; - period: { - start: string; - end: string; - }; - message: string; -} - -export interface ScheduleSettings { - enabled: boolean; - time: string; // "HH:mm" format, default "10:00" - timezone: string; // default "Asia/Shanghai" - report_type: "daily" | "weekly"; // default "daily" - last_run_date?: string; // "YYYY-MM-DD" last execution date - last_run_status?: "success" | "failed"; -} diff --git a/types/workflow.ts b/types/workflow.ts index dd99a48..b597f4e 100644 --- a/types/workflow.ts +++ b/types/workflow.ts @@ -40,8 +40,7 @@ export interface AppWithWorkflow { id: number; name: string; description: string; - // 触发方式(列名沿用 app_type)。Tool / Plugin 不再是可选项,仅兼容存量数据。 - app_type: "Chat" | "Subscription" | "Email" | "Custom" | "Tool" | "Plugin"; + app_type: "Chat" | "Custom"; platform: "Web" | "Wechat" | "Plugin" | "Feishu" | "iOS" | "Android"; user_id: number; ai_model: string;