From f1571743418cb2b1b9373407e0a6ce4e4f0f7cc2 Mon Sep 17 00:00:00 2001 From: mzaxd Date: Tue, 24 Feb 2026 09:38:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=89=8D=E6=B2=BF?= =?UTF-8?q?=E4=BF=A1=E5=8F=B7=E8=81=9A=E5=90=88=E9=A1=B5=E9=9D=A2=E4=B8=8E?= =?UTF-8?q?Webhook=E5=85=A5=E5=BA=93=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/AGENTS.md | 2 + docs/n8n/frontier-signals-workflow.md | 79 +++ prisma/schema.prisma | 25 + src/app/[locale]/about/page.tsx | 187 +++++++ src/app/[locale]/layout.tsx | 8 +- src/app/[locale]/signals/page.tsx | 64 +++ src/app/api/signals/route.ts | 281 +++++++++++ src/app/api/webhook/signals/route.ts | 171 +++++++ src/components/signals/SignalFeedClient.tsx | 509 ++++++++++++++++++++ src/lib/validations.ts | 67 +++ src/messages/en.json | 84 ++++ src/messages/zh.json | 84 ++++ 12 files changed, 1560 insertions(+), 1 deletion(-) create mode 100644 docs/n8n/frontier-signals-workflow.md create mode 100644 src/app/[locale]/about/page.tsx create mode 100644 src/app/[locale]/signals/page.tsx create mode 100644 src/app/api/signals/route.ts create mode 100644 src/app/api/webhook/signals/route.ts create mode 100644 src/components/signals/SignalFeedClient.tsx diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 1fe4f28..19fee8d 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -19,6 +19,7 @@ docs/ ├── n8n/ # n8n workflow documentation │ ├── historical-workflow-design.md │ ├── incremental-workflow-design.md +│ ├── frontier-signals-workflow.md │ ├── tag-janitor-workflow.json │ ├── project-tag-reset-workflow.json │ ├── project-tag-reset-workflow.md @@ -45,6 +46,7 @@ docs/ | `discovery-workflow.md` | Project discovery architecture | | `plans/*.md` | Feature design & implementation specs | | `n8n/*.md` | n8n workflow design docs | +| `n8n/frontier-signals-workflow.md` | Multi-source frontier signals ingestion workflow (AI Agent filter) | | `n8n/project-tag-reset-workflow.json` | Project tag reset workflow (multi-AI category classification) | ## FOR AI AGENTS diff --git a/docs/n8n/frontier-signals-workflow.md b/docs/n8n/frontier-signals-workflow.md new file mode 100644 index 0000000..86d9854 --- /dev/null +++ b/docs/n8n/frontier-signals-workflow.md @@ -0,0 +1,79 @@ +# Frontier Signals Workflow + +## Goal + +Aggregate frontier discussions from multiple platforms, keep only **AI Agent-related** signals via AI filtering, and ingest into `POST /api/webhook/signals`. + +## Workflow + +- Workflow name: `前沿信号聚合(多源+AI Agent过滤)` +- Workflow ID: `bAxNZKGq2ApUUiw9` +- Status: `active` +- Trigger: every 4 hours (`Schedule Trigger`) +- Activated at: `2026-02-23` + +## Source Research (Endpoints + Extracted Elements) + +| Source | Endpoint | Node Type | Extracted Elements | +| --- | --- | --- | --- | +| Hacker News | `https://hacker-news.firebaseio.com/v0/topstories.json` + `.../item/{id}.json` | HTTP Request | `title`, `url`, `text`, `score`, `descendants`, `time` | +| GitHub | `https://api.github.com/search/repositories` | HTTP Request | `full_name`, `html_url`, `description`, `topics`, `stargazers_count`, `pushed_at` | +| arXiv | `https://export.arxiv.org/api/query?...` | RSS Read | `title`, `link`, `content/contentSnippet`, `pubDate/isoDate`, `categories` | +| Reddit | `https://www.reddit.com/r/LocalLLaMA/new.json?limit=40` | HTTP Request | `title`, `permalink`, `selftext/url`, `ups`, `num_comments`, `created_utc` | +| Product Hunt | `https://www.producthunt.com/feed` | RSS Read | `title`, `link`, `content/contentSnippet`, `published/updated` | +| Hugging Face | `https://huggingface.co/blog/feed.xml` | RSS Read | `title`, `link`, `content/contentSnippet`, `published/updated` | + +## Why These Nodes + +- `Schedule Trigger`: periodic ingestion +- `HTTP Request` / `RSS Read`: source fetching with stable machine-readable endpoints +- `Code`: per-source normalization and schema-safe cleanup +- `Merge (append)`: multi-source union +- `Remove Duplicates`: `source + sourceUrl` dedupe before/after AI +- `Limit`: cap candidate volume before AI +- `LLM Chain + Structured Output Parser`: relevance filtering + structured sections +- `If`: keep only `shouldKeep=true` +- `HTTP Request` (POST): write to `/api/webhook/signals` + +## Target Contract Mapping + +The workflow emits payload compatible with `SignalWebhookPayloadSchema`: + +- `apiKey`: from `$env.WEBHOOK_API_KEY` +- `signals[]`: + - `source` -> one of: + - `hacker_news` + - `github` + - `arxiv` + - `hugging_face` + - `reddit` + - `product_hunt` + - `sourceUrl`, `title`, `summary`, `topic`, `tags`, `sections`, `engagement`, `publishedAt`, `isActive` + +Sections are constrained to: + +- style: `focus | debate | evidence | action | risk` +- max 6 sections, max 6 items per section + +## AI Filtering Policy + +The AI node does **not** score ideas by novelty/reliability/feasibility. +It only decides whether a signal is about AI Agent topics and then structures content for reading efficiency. + +- Keep (`shouldKeep=true`) if discussion is materially agent-related +- Drop (`shouldKeep=false`) if clearly unrelated to AI Agent +- Ambiguous cases bias to keep + +## Manual Configuration (No Env) + +This workflow is intentionally configured without `$env` usage. + +- Node `构建 Webhook Payload`: + - set `apiKey` to your real webhook key (replace placeholder string) +- Node `发送到 Signals Webhook`: + - set the target URL to your actual API base (current default is `https://agentpark.fun/api/webhook/signals`) + +## Notes + +- Reddit source uses JSON API with explicit `User-Agent` headers to reduce 403 blocking risk. +- `n8n_test_workflow` cannot trigger schedule-only workflows via API; runtime verification should be done by waiting for scheduled execution or manual run in n8n UI. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index faba186..581a190 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -100,6 +100,31 @@ model Tag { @@map("tags") } +model Signal { + id String @id @default(cuid()) + source String + sourceUrl String + title String + titleEn String? + summary String + summaryEn String? + topic String? + topicEn String? + tags Json + sections Json + engagement Int @default(0) + publishedAt DateTime + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([source, sourceUrl], map: "uniq_signal_source_url") + @@index([source, publishedAt], map: "idx_signal_source_published") + @@index([isActive, publishedAt], map: "idx_signal_active_published") + @@index([engagement], map: "idx_signal_engagement") + @@map("signals") +} + enum LinkType { WEBSITE GITHUB diff --git a/src/app/[locale]/about/page.tsx b/src/app/[locale]/about/page.tsx new file mode 100644 index 0000000..8365898 --- /dev/null +++ b/src/app/[locale]/about/page.tsx @@ -0,0 +1,187 @@ +import Link from 'next/link' +import type { Metadata } from 'next' +import { getTranslations } from 'next-intl/server' + +interface AboutPageProps { + params: Promise<{ locale: string }> +} + +export async function generateMetadata({ + params, +}: AboutPageProps): Promise { + const { locale } = await params + const t = await getTranslations({ locale, namespace: 'about' }) + + return { + title: t('metaTitle'), + description: t('metaDescription'), + } +} + +export default async function AboutPage({ params }: AboutPageProps) { + const { locale } = await params + const t = await getTranslations('about') + + const capabilityCards = [ + { + icon: 'travel_explore', + title: t('pillarDiscoveryTitle'), + description: t('pillarDiscoveryDescription'), + }, + { + icon: 'fact_check', + title: t('pillarQualityTitle'), + description: t('pillarQualityDescription'), + }, + { + icon: 'auto_awesome', + title: t('pillarContextTitle'), + description: t('pillarContextDescription'), + }, + ] as const + + const workflowSteps = [ + { + icon: 'public', + title: t('workflowStepCollectTitle'), + description: t('workflowStepCollectDescription'), + }, + { + icon: 'tune', + title: t('workflowStepReviewTitle'), + description: t('workflowStepReviewDescription'), + }, + { + icon: 'inventory_2', + title: t('workflowStepPublishTitle'), + description: t('workflowStepPublishDescription'), + }, + { + icon: 'psychology_alt', + title: t('workflowStepSearchTitle'), + description: t('workflowStepSearchDescription'), + }, + ] as const + + const principles = [ + { + icon: 'rule', + title: t('principleNeutralTitle'), + description: t('principleNeutralDescription'), + }, + { + icon: 'visibility', + title: t('principleTransparentTitle'), + description: t('principleTransparentDescription'), + }, + { + icon: 'build_circle', + title: t('principleUsefulTitle'), + description: t('principleUsefulDescription'), + }, + ] as const + + return ( +
+
+
+ +
+

+ hub + {t('heroEyebrow')} +

+

+ {t('heroTitle')} +

+

+ {t('heroDescription')} +

+

+ {t('heroTagline')} +

+ +
+ + {t('ctaProjects')} + + + {t('ctaSubmit')} + +
+
+ +
+
+

{t('missionTitle')}

+

{t('missionDescription')}

+

{t('missionNote')}

+
+ +
+

{t('whatWeDoTitle')}

+

{t('whatWeDoDescription')}

+
+
+ +
+

{t('capabilitiesTitle')}

+
+ {capabilityCards.map((card) => ( +
+ {card.icon} +

{card.title}

+

{card.description}

+
+ ))} +
+
+ +
+
+

{t('workflowTitle')}

+

{t('workflowDescription')}

+
+ {workflowSteps.map((step, index) => ( +
+
0{index + 1}
+ {step.icon} +

{step.title}

+

{step.description}

+
+ ))} +
+
+
+ +
+

{t('principlesTitle')}

+
+ {principles.map((principle) => ( +
+ {principle.icon} +

{principle.title}

+

{principle.description}

+
+ ))} +
+
+ +
+
+

{t('closingTitle')}

+

{t('closingDescription')}

+ + {t('closingCta')} + +
+
+
+ ) +} diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index d0fc91f..03fe52f 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -77,7 +77,13 @@ export default async function LocaleLayout({ + {tNav('signals')} + + {tNav('about')} diff --git a/src/app/[locale]/signals/page.tsx b/src/app/[locale]/signals/page.tsx new file mode 100644 index 0000000..86c55bc --- /dev/null +++ b/src/app/[locale]/signals/page.tsx @@ -0,0 +1,64 @@ +import type { Metadata } from 'next' +import { getTranslations } from 'next-intl/server' +import { SignalFeedClient } from '@/components/signals/SignalFeedClient' + +interface SignalsPageProps { + params: Promise<{ locale: string }> +} + +export async function generateMetadata({ params }: SignalsPageProps): Promise { + const { locale } = await params + const t = await getTranslations({ locale, namespace: 'signals' }) + + return { + title: t('metaTitle'), + description: t('metaDescription'), + } +} + +export default async function SignalsPage({ params }: SignalsPageProps) { + const { locale } = await params + const t = await getTranslations({ locale, namespace: 'signals' }) + + return ( +
+
+
+
+

+ {t('heroEyebrow')} +

+

+ {t('heroTitle')} +

+

+ {t('heroDescription')} +

+
+ + +
+ ) +} diff --git a/src/app/api/signals/route.ts b/src/app/api/signals/route.ts new file mode 100644 index 0000000..0686ce8 --- /dev/null +++ b/src/app/api/signals/route.ts @@ -0,0 +1,281 @@ +import { NextRequest, NextResponse } from 'next/server' +import type { Prisma } from '@prisma/client' +import { ZodError } from 'zod' +import { prisma } from '@/lib/prisma' +import { + SignalQuerySchema, + type SignalQuery, + type SignalSectionStyle, + type SignalSource, +} from '@/lib/validations' + +interface SignalSectionView { + id: string + title: string + style: SignalSectionStyle + items: string[] +} + +interface SignalView { + id: string + title: string + summary: string + sections: SignalSectionView[] + source: SignalSource + sourceUrl: string + engagement: number + publishedAt: string + topic: string + tags: string[] +} + +const VALID_SECTION_STYLES: SignalSectionStyle[] = ['focus', 'debate', 'evidence', 'action', 'risk'] + +function isSignalSource(value: string): value is SignalSource { + return ( + value === 'hacker_news' || + value === 'github' || + value === 'arxiv' || + value === 'hugging_face' || + value === 'reddit' || + value === 'product_hunt' + ) +} + +function pickLocalizedText(locale: SignalQuery['locale'], primary: string, secondary?: string | null): string { + if (locale === 'en' && secondary && secondary.trim().length > 0) { + return secondary + } + + return primary +} + +function parseTags(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + + return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) +} + +function parseSections(value: unknown, locale: SignalQuery['locale'], fallbackSummary: string): SignalSectionView[] { + if (!Array.isArray(value)) { + return [ + { + id: 'focus', + title: locale === 'en' ? 'Core Idea' : '核心观点', + style: 'focus', + items: [fallbackSummary], + }, + ] + } + + const parsed = value + .map((rawSection) => { + if (!rawSection || typeof rawSection !== 'object') { + return null + } + + const section = rawSection as { + id?: unknown + title?: unknown + titleEn?: unknown + style?: unknown + items?: unknown + itemsEn?: unknown + } + + const id = typeof section.id === 'string' && section.id.trim().length > 0 ? section.id : 'section' + const style = + typeof section.style === 'string' && VALID_SECTION_STYLES.includes(section.style as SignalSectionStyle) + ? (section.style as SignalSectionStyle) + : 'focus' + + const titleZh = typeof section.title === 'string' && section.title.trim().length > 0 ? section.title : '核心观点' + const titleEn = typeof section.titleEn === 'string' && section.titleEn.trim().length > 0 ? section.titleEn : undefined + + const itemsZh = Array.isArray(section.items) + ? section.items.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + : [] + const itemsEn = Array.isArray(section.itemsEn) + ? section.itemsEn.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + : [] + + const items = locale === 'en' && itemsEn.length > 0 ? itemsEn : itemsZh + + if (items.length === 0) { + return null + } + + return { + id, + title: locale === 'en' && titleEn ? titleEn : titleZh, + style, + items, + } + }) + .filter((section): section is SignalSectionView => section !== null) + + if (parsed.length > 0) { + return parsed + } + + return [ + { + id: 'focus', + title: locale === 'en' ? 'Core Idea' : '核心观点', + style: 'focus', + items: [fallbackSummary], + }, + ] +} + +function toSignalView(row: { + id: string + source: string + sourceUrl: string + title: string + titleEn: string | null + summary: string + summaryEn: string | null + topic: string | null + topicEn: string | null + tags: Prisma.JsonValue + sections: Prisma.JsonValue + engagement: number + publishedAt: Date +}, locale: SignalQuery['locale']): SignalView | null { + if (!isSignalSource(row.source)) { + return null + } + + const title = pickLocalizedText(locale, row.title, row.titleEn) + const summary = pickLocalizedText(locale, row.summary, row.summaryEn) + + return { + id: row.id, + title, + summary, + sections: parseSections(row.sections, locale, summary), + source: row.source, + sourceUrl: row.sourceUrl, + engagement: row.engagement, + publishedAt: row.publishedAt.toISOString(), + topic: pickLocalizedText(locale, row.topic || '', row.topicEn || undefined), + tags: parseTags(row.tags), + } +} + +export async function GET(request: NextRequest) { + try { + const { searchParams } = request.nextUrl + + const parsed = SignalQuerySchema.parse({ + q: searchParams.get('q') || undefined, + source: searchParams.get('source') || undefined, + sort: searchParams.get('sort') || undefined, + cursor: searchParams.get('cursor') || undefined, + limit: searchParams.get('limit') || undefined, + locale: searchParams.get('locale') || undefined, + }) + + if (parsed.cursor) { + const cursorExists = await prisma.signal.findUnique({ + where: { id: parsed.cursor }, + select: { id: true }, + }) + + if (!cursorExists) { + return NextResponse.json( + { + error: 'Invalid cursor', + message: 'Cursor not found. Please refresh and retry.', + }, + { status: 400 } + ) + } + } + + const where: Prisma.SignalWhereInput = { + isActive: true, + ...(parsed.source ? { source: parsed.source } : {}), + ...(parsed.q + ? { + OR: [ + { title: { contains: parsed.q, mode: 'insensitive' } }, + { titleEn: { contains: parsed.q, mode: 'insensitive' } }, + { summary: { contains: parsed.q, mode: 'insensitive' } }, + { summaryEn: { contains: parsed.q, mode: 'insensitive' } }, + { topic: { contains: parsed.q, mode: 'insensitive' } }, + { topicEn: { contains: parsed.q, mode: 'insensitive' } }, + ], + } + : {}), + } + + const orderBy: Prisma.SignalOrderByWithRelationInput[] = + parsed.sort === 'hot' + ? [{ engagement: 'desc' }, { publishedAt: 'desc' }, { id: 'desc' }] + : [{ publishedAt: 'desc' }, { id: 'desc' }] + + const rows = await prisma.signal.findMany({ + where, + orderBy, + take: parsed.limit + 1, + ...(parsed.cursor + ? { + cursor: { id: parsed.cursor }, + skip: 1, + } + : {}), + select: { + id: true, + source: true, + sourceUrl: true, + title: true, + titleEn: true, + summary: true, + summaryEn: true, + topic: true, + topicEn: true, + tags: true, + sections: true, + engagement: true, + publishedAt: true, + }, + }) + + const hasMore = rows.length > parsed.limit + const pageRows = hasMore ? rows.slice(0, parsed.limit) : rows + const nextCursor = hasMore ? pageRows[pageRows.length - 1]?.id || null : null + + const items = pageRows + .map((row) => toSignalView(row, parsed.locale)) + .filter((item): item is SignalView => item !== null) + + return NextResponse.json({ + items, + nextCursor, + hasMore, + }) + } catch (error) { + if (error instanceof ZodError) { + return NextResponse.json( + { + error: 'Invalid query parameters', + details: error.errors, + }, + { status: 400 } + ) + } + + console.error('[Signals API] GET error:', error) + return NextResponse.json( + { + error: 'Failed to fetch signals', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ) + } +} diff --git a/src/app/api/webhook/signals/route.ts b/src/app/api/webhook/signals/route.ts new file mode 100644 index 0000000..c4f1ce3 --- /dev/null +++ b/src/app/api/webhook/signals/route.ts @@ -0,0 +1,171 @@ +import { NextRequest, NextResponse } from 'next/server' +import type { Prisma } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import { isValidApiKey } from '@/lib/auth' +import { + SignalIngestionInputSchema, + SignalWebhookPayloadSchema, + type SignalIngestionInput, + type SignalWebhookPayload, +} from '@/lib/validations' + +function toSectionsJson(sections: SignalIngestionInput['sections']): Prisma.InputJsonValue { + return sections.map((section) => ({ + id: section.id, + style: section.style, + title: section.title, + titleEn: section.titleEn || null, + items: section.items, + itemsEn: section.itemsEn || [], + })) as Prisma.InputJsonValue +} + +function toTagsJson(tags: SignalIngestionInput['tags']): Prisma.InputJsonValue { + return tags as Prisma.InputJsonValue +} + +export async function POST(request: NextRequest) { + const startTime = Date.now() + + try { + const body = await request.json() + + const validationResult = SignalWebhookPayloadSchema.safeParse(body) + if (!validationResult.success) { + return NextResponse.json( + { + success: false, + error: 'Validation error', + details: validationResult.error.errors.map((e) => e.message), + }, + { status: 400 } + ) + } + + const payload = validationResult.data as SignalWebhookPayload + + if (!isValidApiKey(payload.apiKey)) { + return NextResponse.json( + { + success: false, + error: 'Unauthorized', + details: ['Invalid or missing API Key'], + }, + { status: 401 } + ) + } + + const results = { + processed: payload.signals.length, + created: 0, + updated: 0, + failed: 0, + errors: [] as Array<{ + index: number + field: string + message: string + }>, + } + + for (let i = 0; i < payload.signals.length; i++) { + const signalData = payload.signals[i] + + const itemValidation = SignalIngestionInputSchema.safeParse(signalData) + if (!itemValidation.success) { + results.failed++ + const firstError = itemValidation.error.errors[0] + results.errors.push({ + index: i, + field: firstError?.path.join('.') || 'unknown', + message: firstError?.message || 'Validation failed', + }) + continue + } + + try { + const validSignal = itemValidation.data as SignalIngestionInput + + const existing = await prisma.signal.findUnique({ + where: { + source_sourceUrl: { + source: validSignal.source, + sourceUrl: validSignal.sourceUrl, + }, + }, + select: { id: true }, + }) + + await prisma.signal.upsert({ + where: { + source_sourceUrl: { + source: validSignal.source, + sourceUrl: validSignal.sourceUrl, + }, + }, + update: { + title: validSignal.title, + titleEn: validSignal.titleEn || null, + summary: validSignal.summary, + summaryEn: validSignal.summaryEn || null, + topic: validSignal.topic || null, + topicEn: validSignal.topicEn || null, + tags: toTagsJson(validSignal.tags), + sections: toSectionsJson(validSignal.sections), + engagement: validSignal.engagement, + publishedAt: new Date(validSignal.publishedAt), + isActive: validSignal.isActive, + }, + create: { + source: validSignal.source, + sourceUrl: validSignal.sourceUrl, + title: validSignal.title, + titleEn: validSignal.titleEn || null, + summary: validSignal.summary, + summaryEn: validSignal.summaryEn || null, + topic: validSignal.topic || null, + topicEn: validSignal.topicEn || null, + tags: toTagsJson(validSignal.tags), + sections: toSectionsJson(validSignal.sections), + engagement: validSignal.engagement, + publishedAt: new Date(validSignal.publishedAt), + isActive: validSignal.isActive, + }, + }) + + if (existing) { + results.updated++ + } else { + results.created++ + } + } catch (error) { + console.error(`[Webhook Signals] Error at index ${i}:`, error) + results.failed++ + results.errors.push({ + index: i, + field: 'general', + message: 'Failed to upsert signal. Please check server logs.', + }) + } + } + + const duration = Date.now() - startTime + console.warn( + `[Webhook Signals] Processed ${results.processed} signals in ${duration}ms: ${results.created} created, ${results.updated} updated, ${results.failed} failed` + ) + + return NextResponse.json({ + success: true, + ...results, + }) + } catch (error) { + console.error('[Webhook Signals] Error:', error) + return NextResponse.json( + { + success: false, + error: 'Internal server error', + details: [error instanceof Error ? error.message : 'Unknown error'], + }, + { status: 500 } + ) + } +} diff --git a/src/components/signals/SignalFeedClient.tsx b/src/components/signals/SignalFeedClient.tsx new file mode 100644 index 0000000..ef65fab --- /dev/null +++ b/src/components/signals/SignalFeedClient.tsx @@ -0,0 +1,509 @@ +'use client' + +import Link from 'next/link' +import { useEffect, useMemo, useState } from 'react' +import { + ArrowUpRight, + FlaskConical, + Globe2, + Github, + MessageCircle, + Newspaper, + Rocket, + Search, + Sparkles, + TrendingUp, + type LucideIcon, +} from 'lucide-react' + +export type SignalSource = + | 'hacker_news' + | 'github' + | 'arxiv' + | 'hugging_face' + | 'reddit' + | 'product_hunt' + +export type SignalSectionStyle = 'focus' | 'debate' | 'evidence' | 'action' | 'risk' + +export interface SignalSection { + id: string + title: string + style: SignalSectionStyle + items: string[] +} + +export interface IdeaSignal { + id: string + title: string + summary: string + sections: SignalSection[] + source: SignalSource + sourceUrl: string + engagement: number + publishedAt: string + topic: string + tags: string[] +} + +interface SignalsApiResponse { + items: IdeaSignal[] + nextCursor: string | null + hasMore: boolean +} + +type SortKey = 'latest' | 'hot' +type SourceFilter = SignalSource | 'all' + +const PAGE_SIZE = 12 +const SEARCH_DEBOUNCE_MS = 250 + +interface SignalFeedClientProps { + locale: string + translations: { + searchLabel: string + searchPlaceholder: string + sortLabel: string + sourceLabel: string + noResults: string + viewSource: string + sortLatest: string + sortHot: string + sourceAll: string + sourceHackerNews: string + sourceGithub: string + sourceArxiv: string + sourceHuggingFace: string + sourceReddit: string + sourceProductHunt: string + loadMore: string + loading: string + loadFailed: string + } +} + +interface SourceMeta { + label: string + icon: LucideIcon + chipClass: string + filterClass: string +} + +function formatDate(value: string, locale: string): string { + return new Intl.DateTimeFormat(locale === 'en' ? 'en-US' : 'zh-CN', { + month: 'short', + day: 'numeric', + }).format(new Date(value)) +} + +function getSourceMeta(translations: SignalFeedClientProps['translations']): Record { + return { + hacker_news: { + label: translations.sourceHackerNews, + icon: Newspaper, + chipClass: 'bg-orange-300 border-orange-600 text-black', + filterClass: 'data-[active=true]:bg-orange-300 data-[active=true]:text-black', + }, + github: { + label: translations.sourceGithub, + icon: Github, + chipClass: 'bg-slate-200 border-slate-600 text-black', + filterClass: 'data-[active=true]:bg-slate-200 data-[active=true]:text-black', + }, + arxiv: { + label: translations.sourceArxiv, + icon: FlaskConical, + chipClass: 'bg-cyan-200 border-cyan-700 text-black', + filterClass: 'data-[active=true]:bg-cyan-200 data-[active=true]:text-black', + }, + hugging_face: { + label: translations.sourceHuggingFace, + icon: Sparkles, + chipClass: 'bg-yellow-200 border-yellow-700 text-black', + filterClass: 'data-[active=true]:bg-yellow-200 data-[active=true]:text-black', + }, + reddit: { + label: translations.sourceReddit, + icon: MessageCircle, + chipClass: 'bg-red-200 border-red-700 text-black', + filterClass: 'data-[active=true]:bg-red-200 data-[active=true]:text-black', + }, + product_hunt: { + label: translations.sourceProductHunt, + icon: Rocket, + chipClass: 'bg-fuchsia-200 border-fuchsia-700 text-black', + filterClass: 'data-[active=true]:bg-fuchsia-200 data-[active=true]:text-black', + }, + } +} + +function getSectionStyleClass(style: SignalSectionStyle): string { + switch (style) { + case 'focus': + return 'bg-yellow-50 dark:bg-yellow-900/20' + case 'debate': + return 'bg-red-50 dark:bg-red-900/20' + case 'evidence': + return 'bg-cyan-50 dark:bg-cyan-900/20' + case 'action': + return 'bg-emerald-50 dark:bg-emerald-900/20' + case 'risk': + return 'bg-gray-50 dark:bg-gray-800/40' + default: + return 'bg-white dark:bg-surface-dark' + } +} + +function buildSignalsQuery(params: { + locale: string + limit: number + sort: SortKey + source: SourceFilter + q: string + cursor?: string | null +}): string { + const searchParams = new URLSearchParams({ + locale: params.locale === 'en' ? 'en' : 'zh', + limit: String(params.limit), + sort: params.sort, + }) + + const normalizedQuery = params.q.trim() + if (normalizedQuery.length > 0) { + searchParams.set('q', normalizedQuery) + } + + if (params.source !== 'all') { + searchParams.set('source', params.source) + } + + if (params.cursor) { + searchParams.set('cursor', params.cursor) + } + + return searchParams.toString() +} + +async function fetchSignals(params: { + locale: string + limit: number + sort: SortKey + source: SourceFilter + q: string + cursor?: string | null + signal?: AbortSignal +}): Promise { + const query = buildSignalsQuery(params) + const response = await fetch(`/api/signals?${query}`, { + cache: 'no-store', + signal: params.signal, + }) + + if (!response.ok) { + throw new Error(`Failed to fetch signals: ${response.status}`) + } + + const data = (await response.json()) as Partial + + return { + items: Array.isArray(data.items) ? data.items : [], + nextCursor: typeof data.nextCursor === 'string' ? data.nextCursor : null, + hasMore: Boolean(data.hasMore), + } +} + +export function SignalFeedClient({ locale, translations }: SignalFeedClientProps) { + const [search, setSearch] = useState('') + const [debouncedSearch, setDebouncedSearch] = useState('') + const [source, setSource] = useState('all') + const [sort, setSort] = useState('latest') + + const [signals, setSignals] = useState([]) + const [nextCursor, setNextCursor] = useState(null) + const [hasMore, setHasMore] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [isLoadingMore, setIsLoadingMore] = useState(false) + const [loadError, setLoadError] = useState(false) + + const sourceMeta = useMemo(() => getSourceMeta(translations), [translations]) + + useEffect(() => { + const timer = window.setTimeout(() => { + setDebouncedSearch(search) + }, SEARCH_DEBOUNCE_MS) + + return () => window.clearTimeout(timer) + }, [search]) + + useEffect(() => { + const controller = new AbortController() + let cancelled = false + + async function loadFirstPage() { + setIsLoading(true) + setLoadError(false) + + try { + const page = await fetchSignals({ + locale, + limit: PAGE_SIZE, + sort, + source, + q: debouncedSearch, + signal: controller.signal, + }) + + if (cancelled) { + return + } + + setSignals(page.items) + setNextCursor(page.nextCursor) + setHasMore(page.hasMore) + } catch (error) { + if (!cancelled) { + console.error('[Signals] failed to load first page:', error) + setSignals([]) + setNextCursor(null) + setHasMore(false) + setLoadError(true) + } + } finally { + if (!cancelled) { + setIsLoading(false) + } + } + } + + void loadFirstPage() + + return () => { + cancelled = true + controller.abort() + } + }, [debouncedSearch, locale, sort, source]) + + async function handleLoadMore() { + if (!hasMore || !nextCursor || isLoadingMore) { + return + } + + setIsLoadingMore(true) + + try { + const page = await fetchSignals({ + locale, + limit: PAGE_SIZE, + sort, + source, + q: debouncedSearch, + cursor: nextCursor, + }) + + setSignals((previous) => [...previous, ...page.items]) + setNextCursor(page.nextCursor) + setHasMore(page.hasMore) + } catch (error) { + console.error('[Signals] failed to load more:', error) + setLoadError(true) + } finally { + setIsLoadingMore(false) + } + } + + const sourceLabels: Record = { + all: translations.sourceAll, + hacker_news: translations.sourceHackerNews, + github: translations.sourceGithub, + arxiv: translations.sourceArxiv, + hugging_face: translations.sourceHuggingFace, + reddit: translations.sourceReddit, + product_hunt: translations.sourceProductHunt, + } + + const sortOptions: Array<{ key: SortKey; label: string }> = [ + { key: 'latest', label: translations.sortLatest }, + { key: 'hot', label: translations.sortHot }, + ] + + const sourceOptions: SourceFilter[] = [ + 'all', + 'hacker_news', + 'github', + 'arxiv', + 'hugging_face', + 'reddit', + 'product_hunt', + ] + + const allSourcesMeta: SourceMeta = { + label: translations.sourceAll, + icon: Globe2, + chipClass: 'bg-white border-black text-black', + filterClass: + 'data-[active=true]:bg-black data-[active=true]:text-white dark:data-[active=true]:bg-primary dark:data-[active=true]:text-black', + } + + return ( +
+
+
+ + +
+
+
+ +
+

{translations.sourceLabel}

+
+ {sourceOptions.map((option) => { + const active = source === option + const meta = option === 'all' ? allSourcesMeta : sourceMeta[option] + const SourceIcon = meta.icon + + return ( + + ) + })} +
+
+
+ + {isLoading ? ( +
{translations.loading}
+ ) : loadError && signals.length === 0 ? ( +
{translations.loadFailed}
+ ) : signals.length === 0 ? ( +
{translations.noResults}
+ ) : ( +
+
+ {signals.map((signal) => { + const sourceInfo = sourceMeta[signal.source] + const SourceIcon = sourceInfo.icon + const visibleTags = signal.tags.slice(0, 2) + const hiddenTagCount = Math.max(0, signal.tags.length - visibleTags.length) + + return ( +
+
+

{signal.title}

+ + {formatDate(signal.publishedAt, locale)} + +
+ +

+ {signal.summary} +

+ +
+ {signal.sections.map((section) => ( +
+

{section.title}

+
+ {section.items.slice(0, 2).map((item) => ( +

+ {item} +

+ ))} +
+ {section.items.length > 2 ? ( +

+ +{section.items.length - 2} +

+ ) : null} +
+ ))} +
+ +
+
+ {visibleTags.map((tag) => ( + + {tag} + + ))} + {hiddenTagCount > 0 ? +{hiddenTagCount} : null} + + {translations.viewSource} +
+ +
+
+
+
+ ) + })} +
+ + {hasMore ? ( +
+ +
+ ) : null} +
+ )} +
+ ) +} diff --git a/src/lib/validations.ts b/src/lib/validations.ts index 6c251c5..18dc47b 100644 --- a/src/lib/validations.ts +++ b/src/lib/validations.ts @@ -155,6 +155,67 @@ export const AIEventInputSchema = z.object({ sourceUrl: z.string().url().max(2000).optional(), }); +// ================================ +// Signals Schemas +// ================================ + +export const SignalSourceEnum = z.enum([ + "hacker_news", + "github", + "arxiv", + "hugging_face", + "reddit", + "product_hunt", +]); + +export const SignalSectionStyleEnum = z.enum([ + "focus", + "debate", + "evidence", + "action", + "risk", +]); + +export const SignalSectionSchema = z.object({ + id: z.string().min(1).max(60), + style: SignalSectionStyleEnum, + title: z.string().min(1).max(80), + titleEn: z.string().max(80).optional(), + items: z.array(z.string().min(1).max(300)).min(1).max(6), + itemsEn: z.array(z.string().min(1).max(300)).max(6).optional(), +}); + +export const SignalIngestionInputSchema = z.object({ + source: SignalSourceEnum, + sourceUrl: z.string().url().max(2000), + title: z.string().min(1).max(300), + titleEn: z.string().max(300).optional(), + summary: z.string().min(1).max(2000), + summaryEn: z.string().max(2000).optional(), + topic: z.string().max(100).optional(), + topicEn: z.string().max(100).optional(), + tags: z.array(z.string().min(1).max(40)).max(20).default([]), + sections: z.array(SignalSectionSchema).min(1).max(6), + engagement: z.coerce.number().int().nonnegative().default(0), + publishedAt: z.string().datetime({ offset: true }), + isActive: z.boolean().default(true), +}); + +export const SignalWebhookPayloadSchema = WebhookAuthSchema.extend({ + signals: z.array(SignalIngestionInputSchema).min(1).max(100), +}); + +export const SignalSortEnum = z.enum(["latest", "hot"]); + +export const SignalQuerySchema = z.object({ + q: z.string().trim().max(120).optional(), + source: SignalSourceEnum.optional(), + sort: SignalSortEnum.default("latest"), + cursor: z.string().min(1).optional(), + limit: z.coerce.number().int().positive().max(50).default(12), + locale: z.enum(["zh", "en"]).default("zh"), +}); + // ================================ // Query Schemas // ================================ @@ -318,6 +379,12 @@ export type ChatMode = z.infer; export type ChatRole = z.infer; export type ChatBlockType = z.infer; export type ChatEventType = z.infer; +export type SignalSource = z.infer; +export type SignalSectionStyle = z.infer; +export type SignalSectionInput = z.infer; +export type SignalIngestionInput = z.infer; +export type SignalWebhookPayload = z.infer; +export type SignalQuery = z.infer; export type ChatJobStatus = z.infer; export type ChatFeedbackRating = z.infer; export type ChatCitation = z.infer; diff --git a/src/messages/en.json b/src/messages/en.json index f39cbf1..8adce6c 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -124,9 +124,93 @@ "showFilterPanel": "Show Filters", "hideFilterPanel": "Hide Filters" }, + "about": { + "metaTitle": "About Agent Park", + "metaDescription": "Learn what Agent Park is, how it works, and the standards behind the content.", + "heroEyebrow": "About This Site", + "heroTitle": "How Agent Park Is Built", + "heroDescription": "Agent Park is a project navigator for AI builders. We focus on making projects discoverable, understandable, and reusable, instead of just collecting links.", + "heroTagline": "Help great AI projects get found, and understood in the right context.", + "ctaProjects": "Browse Projects", + "ctaSubmit": "Submit Project", + "missionTitle": "Our Goal", + "missionDescription": "New AI tools, repos, and research directions appear every day, and so does noise. We are building a long-term directory with consistent project structure, bilingual context, and practical details that are easy to verify.", + "missionNote": "We optimize for long-term usefulness, not short-term hype.", + "whatWeDoTitle": "What We Actually Do", + "whatWeDoDescription": "We combine automated discovery, deduplicated ingestion, and structured indexing to turn scattered AI project information into a searchable knowledge entry point.", + "capabilitiesTitle": "Core Capabilities", + "pillarDiscoveryTitle": "Continuous Discovery", + "pillarDiscoveryDescription": "Task pipelines keep collecting new candidates from trend lists, topic feeds, and manual submissions.", + "pillarQualityTitle": "Quality Controls", + "pillarQualityDescription": "Multi-level deduplication and structured validation reduce repeated, broken, and vague entries.", + "pillarContextTitle": "Context Layer", + "pillarContextDescription": "Keyword clouds, timeline views, and semantic search help you connect projects across themes and time.", + "workflowTitle": "Data Workflow", + "workflowDescription": "From discovery to retrieval, each step is designed for reusable information.", + "workflowStepCollectTitle": "Collect Sources", + "workflowStepCollectDescription": "Automated tasks and manual input feed one candidate queue.", + "workflowStepReviewTitle": "Deduplicate & Validate", + "workflowStepReviewDescription": "Projects are merged through GitHub URL, website URL, and slug matching rules.", + "workflowStepPublishTitle": "Structured Ingestion", + "workflowStepPublishDescription": "We store normalized fields with tags, links, and bilingual descriptions.", + "workflowStepSearchTitle": "Search & Retrieval", + "workflowStepSearchDescription": "Filtering, sorting, and RAG search make projects easier to find and compare.", + "principlesTitle": "Content Principles", + "principleNeutralTitle": "Stay Neutral", + "principleNeutralDescription": "No exaggerated claims and no forced endorsements. We aim for useful, objective context.", + "principleTransparentTitle": "Keep Sources Visible", + "principleTransparentDescription": "Official websites and repositories stay close to the content so users can verify quickly.", + "principleUsefulTitle": "Built for Real Decisions", + "principleUsefulDescription": "The structure is designed to answer practical questions: when to use it, why, and with what.", + "closingTitle": "Help Improve This Directory", + "closingDescription": "If you are building an AI product, or found a project worth tracking, submit it. We will keep improving data quality and browsing experience.", + "closingCta": "Explore Project List" + }, + "signals": { + "metaTitle": "Frontier Signals - Agent Park", + "metaDescription": "Aggregated AI-agent discussions across platforms so users can quickly see what people are talking about.", + "heroEyebrow": "Discussion Radar", + "heroTitle": "What People Are Discussing Right Now", + "heroDescription": "This page does not judge ideas. It focuses on efficient aggregation and readable structure so users can scan hot discussions, key insights, and disagreements without browsing every source manually.", + "searchLabel": "Search Discussions", + "searchPlaceholder": "Search title, summary, topic, or tags", + "sortLabel": "Sort", + "sourceLabel": "Source", + "noResults": "No matching results. Try adjusting your filters.", + "viewSource": "View Original Source", + "discussionSignalTitle": "Discussion Signal", + "discussionFocusTitle": "Key Insights", + "discussionAnglesTitle": "Disagreements", + "sortLatest": "Latest First", + "sortHot": "Most Discussed", + "sourceAll": "All Sources", + "sourceHackerNews": "Hacker News", + "sourceGithub": "GitHub", + "sourceArxiv": "arXiv", + "sourceHuggingFace": "Hugging Face", + "sourceReddit": "Reddit", + "sourceProductHunt": "Product Hunt", + "loadMore": "Load More", + "loading": "Loading...", + "loadFailed": "Failed to load. Please try again.", + "hotTopicsTitle": "Hot Topic Clusters", + "hotTopicsDescription": "Grouped by topic with item count, engagement volume, and source coverage for fast trend scanning.", + "hotTopicsCount": "Items", + "hotTopicsEngagement": "Engagement", + "hotTopicsSources": "Source Coverage", + "agendaTitle": "Page Principle", + "agendaDescription": "We do not rate ideas as right or wrong. If a discussion is meaningful, even immature ideas are kept and surfaced.", + "contractTitle": "n8n Field Contract", + "contractDescription": "Every visual element maps to fields that can be produced from n8n workflow outputs.", + "contractRawTitle": "Source-Crawled Fields", + "contractRawFields": "title, source, sourceUrl, publishedAt, sourceSignal, engagement, tags, topic", + "contractAiTitle": "Structured Extraction Fields", + "contractAiFields": "summary, insightBullets, discussionAngles" + }, "navigation": { "home": "Home", "projects": "Projects", + "signals": "Signals", "about": "About", "submitProject": "SUBMIT PROJECT" }, diff --git a/src/messages/zh.json b/src/messages/zh.json index 42bc72d..83e0536 100644 --- a/src/messages/zh.json +++ b/src/messages/zh.json @@ -124,9 +124,93 @@ "showFilterPanel": "展开筛选器", "hideFilterPanel": "收起筛选器" }, + "about": { + "metaTitle": "关于 Agent Park", + "metaDescription": "了解 Agent Park 的定位、工作方式和内容标准。", + "heroEyebrow": "关于这个站点", + "heroTitle": "Agent Park 是如何构建的", + "heroDescription": "Agent Park 是一个面向 AI 构建者的项目导航站。我们聚焦“可被发现、可被理解、可被复用”的项目信息,而不是只做链接堆叠。", + "heroTagline": "让优质 AI 项目更容易被找到,也更容易被正确理解。", + "ctaProjects": "浏览项目", + "ctaSubmit": "提交项目", + "missionTitle": "我们的目标", + "missionDescription": "每天都有大量 AI 产品、开源仓库和研究方向出现,但信息噪声也在同步增长。我们希望建立一个长期可维护的目录:用统一结构记录项目,用双语内容降低理解门槛,并尽量保留每个项目的真实上下文。", + "missionNote": "我们优先关注长期价值,而不是短期热度。", + "whatWeDoTitle": "我们在做什么", + "whatWeDoDescription": "通过自动发现、去重入库和结构化索引,把分散在 GitHub、官网和社区的 AI 项目整理成可以搜索、比较和追踪的知识入口。", + "capabilitiesTitle": "核心能力", + "pillarDiscoveryTitle": "持续发现", + "pillarDiscoveryDescription": "通过任务系统持续收集新增项目来源,覆盖趋势榜单、专题入口和人工提交。", + "pillarQualityTitle": "质量控制", + "pillarQualityDescription": "采用多级去重策略和结构化字段校验,尽量减少重复、失效和模糊信息。", + "pillarContextTitle": "上下文连接", + "pillarContextDescription": "提供关键词云、时间线和语义搜索能力,帮助你快速建立项目之间的关联认知。", + "workflowTitle": "数据工作流", + "workflowDescription": "从发现到检索,每个环节都围绕“信息可复用”设计。", + "workflowStepCollectTitle": "采集来源", + "workflowStepCollectDescription": "自动任务与人工输入并行,形成候选项目池。", + "workflowStepReviewTitle": "去重与校验", + "workflowStepReviewDescription": "按 GitHub / 官网 / slug 多层规则合并重复项目。", + "workflowStepPublishTitle": "结构化入库", + "workflowStepPublishDescription": "写入统一字段,保留标签、链接和多语言描述。", + "workflowStepSearchTitle": "搜索与推荐", + "workflowStepSearchDescription": "通过筛选、排序和 RAG 搜索让项目真正可发现。", + "principlesTitle": "内容原则", + "principleNeutralTitle": "保持中立", + "principleNeutralDescription": "不做夸张宣传,不为任何单一工具背书,尽量提供客观信息。", + "principleTransparentTitle": "来源可追溯", + "principleTransparentDescription": "优先保留官网、仓库等原始链接,便于自行验证与深入。", + "principleUsefulTitle": "面向实践", + "principleUsefulDescription": "内容组织优先服务“怎么用、何时用、和谁一起用”的实际决策。", + "closingTitle": "一起把这个目录做得更好", + "closingDescription": "如果你在做 AI 项目,或者发现了值得收录的工具,欢迎提交给我们。我们会持续优化数据质量与浏览体验。", + "closingCta": "去项目列表看看" + }, + "signals": { + "metaTitle": "前沿信号 - Agent Park", + "metaDescription": "聚合各平台 AI Agent 前沿讨论,帮助你快速知道大家在聊什么。", + "heroEyebrow": "前沿讨论雷达", + "heroTitle": "大家正在讨论什么", + "heroDescription": "这一页不做价值评判,只做高效聚合与结构化阅读。你会看到不同平台上的热门讨论、核心观点和分歧点,省去逐个平台翻找的时间。", + "searchLabel": "搜索讨论", + "searchPlaceholder": "搜索标题、摘要、主题或标签", + "sortLabel": "排序", + "sourceLabel": "来源", + "noResults": "没有匹配结果,请调整筛选条件。", + "viewSource": "查看原始来源", + "discussionSignalTitle": "讨论信号", + "discussionFocusTitle": "核心观点", + "discussionAnglesTitle": "争议与分歧", + "sortLatest": "最新优先", + "sortHot": "热度优先", + "sourceAll": "全部来源", + "sourceHackerNews": "Hacker News", + "sourceGithub": "GitHub", + "sourceArxiv": "arXiv", + "sourceHuggingFace": "Hugging Face", + "sourceReddit": "Reddit", + "sourceProductHunt": "Product Hunt", + "loadMore": "加载更多", + "loading": "加载中...", + "loadFailed": "加载失败,请稍后重试。", + "hotTopicsTitle": "热门话题聚合", + "hotTopicsDescription": "按 topic 聚类,展示条数、互动量和来源覆盖,用于快速扫一眼“现在在聊什么”。", + "hotTopicsCount": "条数", + "hotTopicsEngagement": "互动量", + "hotTopicsSources": "来源覆盖", + "agendaTitle": "本页原则", + "agendaDescription": "不对观点做“对错/优劣”判断,只呈现讨论内容本身。哪怕是不成熟想法,只要有讨论价值,也会被保留。", + "contractTitle": "n8n 字段契约", + "contractDescription": "页面元素都对应可由 n8n 输出的字段,避免出现无法供数的设计组件。", + "contractRawTitle": "来源抓取字段", + "contractRawFields": "title, source, sourceUrl, publishedAt, sourceSignal, engagement, tags, topic", + "contractAiTitle": "结构化提炼字段", + "contractAiFields": "summary, insightBullets, discussionAngles" + }, "navigation": { "home": "首页", "projects": "项目列表", + "signals": "前沿信号", "about": "关于", "submitProject": "提交项目" },