feat: 新增前沿信号聚合页面与Webhook入库能力

This commit is contained in:
2026-02-24 09:38:29 +08:00
parent 4e30ddefc5
commit f157174341
12 changed files with 1560 additions and 1 deletions
+2
View File
@@ -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
+79
View File
@@ -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.
+25
View File
@@ -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
+187
View File
@@ -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<Metadata> {
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 (
<main className="relative overflow-hidden pb-20">
<div className="absolute top-16 left-8 w-20 h-20 border-2 border-black dark:border-gray-600 rotate-12 opacity-20 hidden lg:block"></div>
<div className="absolute top-28 right-10 w-28 h-16 border-2 border-black dark:border-gray-600 -rotate-12 opacity-20 hidden lg:block"></div>
<section className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 pt-14 md:pt-20 pb-12 text-center">
<p className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-black border-2 border-black font-display text-xs font-bold uppercase tracking-wide shadow-neo-sm">
<span className="material-icons text-base">hub</span>
{t('heroEyebrow')}
</p>
<h1 className="mt-6 font-display text-4xl md:text-6xl font-bold leading-tight tracking-tight">
{t('heroTitle')}
</h1>
<p className="mt-5 text-base md:text-lg text-gray-700 dark:text-gray-300 max-w-3xl mx-auto">
{t('heroDescription')}
</p>
<p className="mt-4 font-display text-sm md:text-base font-bold uppercase tracking-wide">
{t('heroTagline')}
</p>
<div className="mt-8 flex flex-wrap justify-center gap-4">
<Link
href={`/${locale}/projects`}
className="neo-btn bg-primary text-black px-6 py-3 text-sm"
>
{t('ctaProjects')}
</Link>
<Link
href={`/${locale}/projects`}
className="neo-btn bg-white dark:bg-surface-dark px-6 py-3 text-sm"
>
{t('ctaSubmit')}
</Link>
</div>
</section>
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 grid gap-6 lg:grid-cols-[1.2fr_1fr] mb-14">
<article className="neo-card p-6 md:p-8">
<h2 className="font-display text-2xl md:text-3xl font-bold mb-4">{t('missionTitle')}</h2>
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">{t('missionDescription')}</p>
<p className="mt-4 font-display text-sm font-bold uppercase tracking-wide">{t('missionNote')}</p>
</article>
<article className="neo-card p-6 md:p-8 bg-primary text-black">
<h2 className="font-display text-xl md:text-2xl font-bold mb-3">{t('whatWeDoTitle')}</h2>
<p className="leading-relaxed">{t('whatWeDoDescription')}</p>
</article>
</section>
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mb-14">
<h2 className="font-display text-2xl md:text-3xl font-bold mb-6">{t('capabilitiesTitle')}</h2>
<div className="grid gap-6 md:grid-cols-3">
{capabilityCards.map((card) => (
<article key={card.title} className="neo-card p-6 md:p-7">
<span className="material-icons text-3xl mb-4">{card.icon}</span>
<h3 className="font-display text-lg font-bold mb-2">{card.title}</h3>
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{card.description}</p>
</article>
))}
</div>
</section>
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mb-14">
<div className="neo-card p-6 md:p-8">
<h2 className="font-display text-2xl md:text-3xl font-bold mb-2">{t('workflowTitle')}</h2>
<p className="text-sm md:text-base text-gray-700 dark:text-gray-300 mb-6">{t('workflowDescription')}</p>
<div className="grid gap-4 md:grid-cols-4">
{workflowSteps.map((step, index) => (
<article key={step.title} className="border-2 border-black dark:border-gray-600 bg-white dark:bg-surface-dark p-4">
<div className="font-display text-xs font-bold uppercase mb-2">0{index + 1}</div>
<span className="material-icons text-2xl mb-2">{step.icon}</span>
<h3 className="font-display text-base font-bold mb-1">{step.title}</h3>
<p className="text-xs text-gray-700 dark:text-gray-300 leading-relaxed">{step.description}</p>
</article>
))}
</div>
</div>
</section>
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mb-14">
<h2 className="font-display text-2xl md:text-3xl font-bold mb-6">{t('principlesTitle')}</h2>
<div className="grid gap-6 md:grid-cols-3">
{principles.map((principle) => (
<article key={principle.title} className="neo-card p-6 md:p-7">
<span className="material-icons text-3xl mb-4">{principle.icon}</span>
<h3 className="font-display text-lg font-bold mb-2">{principle.title}</h3>
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{principle.description}</p>
</article>
))}
</div>
</section>
<section className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="neo-card bg-primary text-black p-8 md:p-10 text-center">
<h2 className="font-display text-2xl md:text-3xl font-bold mb-3">{t('closingTitle')}</h2>
<p className="max-w-2xl mx-auto mb-6 leading-relaxed">{t('closingDescription')}</p>
<Link href={`/${locale}/projects`} className="neo-btn inline-flex bg-white text-black px-6 py-3 text-sm">
{t('closingCta')}
</Link>
</div>
</section>
</main>
)
}
+7 -1
View File
@@ -77,7 +77,13 @@ export default async function LocaleLayout({
</Link>
<Link
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
href="#"
href={`/${locale}/signals`}
>
{tNav('signals')}
</Link>
<Link
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
href={`/${locale}/about`}
>
{tNav('about')}
</Link>
+64
View File
@@ -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<Metadata> {
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 (
<main className="container mx-auto max-w-7xl px-4 py-12 md:py-16">
<section className="neo-card p-7 md:p-10 mb-8 relative overflow-hidden">
<div className="absolute -right-6 -top-6 h-24 w-24 rotate-12 border-2 border-black opacity-20 dark:border-gray-500" />
<div className="absolute -bottom-6 -left-6 h-14 w-28 -rotate-12 border-2 border-black opacity-20 dark:border-gray-500" />
<p className="inline-flex items-center gap-2 border-2 border-black bg-primary px-3 py-2 font-display text-xs font-bold uppercase text-black shadow-neo-sm">
{t('heroEyebrow')}
</p>
<h1 className="mt-5 font-display text-4xl font-bold leading-tight tracking-tight md:text-5xl">
{t('heroTitle')}
</h1>
<p className="mt-4 max-w-4xl text-base leading-relaxed text-gray-700 dark:text-gray-300 md:text-lg">
{t('heroDescription')}
</p>
</section>
<SignalFeedClient
locale={locale}
translations={{
searchLabel: t('searchLabel'),
searchPlaceholder: t('searchPlaceholder'),
sortLabel: t('sortLabel'),
sourceLabel: t('sourceLabel'),
noResults: t('noResults'),
viewSource: t('viewSource'),
sortLatest: t('sortLatest'),
sortHot: t('sortHot'),
sourceAll: t('sourceAll'),
sourceHackerNews: t('sourceHackerNews'),
sourceGithub: t('sourceGithub'),
sourceArxiv: t('sourceArxiv'),
sourceHuggingFace: t('sourceHuggingFace'),
sourceReddit: t('sourceReddit'),
sourceProductHunt: t('sourceProductHunt'),
loadMore: t('loadMore'),
loading: t('loading'),
loadFailed: t('loadFailed'),
}}
/>
</main>
)
}
+281
View File
@@ -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 }
)
}
}
+171
View File
@@ -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 }
)
}
}
+509
View File
@@ -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<SignalSource, SourceMeta> {
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<SignalsApiResponse> {
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<SignalsApiResponse>
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<SourceFilter>('all')
const [sort, setSort] = useState<SortKey>('latest')
const [signals, setSignals] = useState<IdeaSignal[]>([])
const [nextCursor, setNextCursor] = useState<string | null>(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<SourceFilter, string> = {
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 (
<div className="space-y-4">
<article className="neo-card p-3.5 md:p-4">
<div className="grid grid-cols-1 gap-2.5 lg:grid-cols-2">
<label className="flex items-center gap-3 neo-input p-3">
<Search className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">{translations.searchLabel}</span>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={translations.searchPlaceholder}
className="w-full bg-transparent outline-none font-sans text-sm"
/>
</label>
<div className="flex items-center gap-2 neo-input p-2">
<TrendingUp className="h-4 w-4 ml-1" aria-hidden="true" />
<label htmlFor="signal-sort" className="font-display text-xs font-bold uppercase">
{translations.sortLabel}
</label>
<select
id="signal-sort"
value={sort}
onChange={(event) => setSort(event.target.value as SortKey)}
className="ml-auto bg-transparent outline-none text-sm font-display font-bold cursor-pointer"
>
{sortOptions.map((option) => (
<option key={option.key} value={option.key}>
{option.label}
</option>
))}
</select>
</div>
</div>
<div className="mt-2.5">
<p className="mb-2 font-display text-xs font-bold uppercase">{translations.sourceLabel}</p>
<div className="flex flex-wrap gap-2">
{sourceOptions.map((option) => {
const active = source === option
const meta = option === 'all' ? allSourcesMeta : sourceMeta[option]
const SourceIcon = meta.icon
return (
<button
key={option}
type="button"
data-active={active}
onClick={() => setSource(option)}
className={`min-h-10 border-2 border-black bg-white px-3 py-1.5 font-display text-xs font-bold uppercase transition-colors cursor-pointer hover:bg-gray-100 dark:border-gray-500 dark:bg-surface-dark dark:hover:bg-gray-800 ${meta.filterClass}`}
>
<span className="inline-flex items-center gap-1.5">
<SourceIcon className="h-3.5 w-3.5" aria-hidden="true" />
{sourceLabels[option]}
</span>
</button>
)
})}
</div>
</div>
</article>
{isLoading ? (
<article className="neo-card p-8 text-center font-display font-bold">{translations.loading}</article>
) : loadError && signals.length === 0 ? (
<article className="neo-card p-8 text-center font-display font-bold">{translations.loadFailed}</article>
) : signals.length === 0 ? (
<article className="neo-card p-8 text-center font-display font-bold">{translations.noResults}</article>
) : (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-2.5 lg:grid-cols-2">
{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 (
<article key={signal.id} className="neo-card relative p-3.5">
<div className="flex items-start justify-between gap-3">
<h2 className="line-clamp-2 font-display text-lg font-bold leading-tight md:text-xl">{signal.title}</h2>
<span className="font-display text-xs font-bold uppercase text-gray-500 dark:text-gray-400 shrink-0">
{formatDate(signal.publishedAt, locale)}
</span>
</div>
<p className="mt-1.5 line-clamp-3 text-sm leading-relaxed text-gray-700 dark:text-gray-300">
{signal.summary}
</p>
<div className="mt-2 flex gap-1.5 overflow-x-auto pb-1">
{signal.sections.map((section) => (
<div
key={`${signal.id}-${section.id}`}
className={`min-w-[180px] flex-1 border-2 border-black p-2 dark:border-gray-600 ${getSectionStyleClass(section.style)}`}
>
<p className="mb-1 font-display text-[10px] font-bold uppercase">{section.title}</p>
<div className="space-y-1">
{section.items.slice(0, 2).map((item) => (
<p key={item} className="line-clamp-2 text-xs leading-relaxed text-gray-800 dark:text-gray-200">
{item}
</p>
))}
</div>
{section.items.length > 2 ? (
<p className="mt-1 font-display text-[10px] font-bold uppercase text-gray-600 dark:text-gray-400">
+{section.items.length - 2}
</p>
) : null}
</div>
))}
</div>
<div className="mt-2 border-t-2 border-black pt-2 dark:border-gray-600">
<div className="flex min-h-8 flex-wrap items-center justify-end gap-1.5 pl-28">
{visibleTags.map((tag) => (
<span key={tag} className="neo-tag">
{tag}
</span>
))}
{hiddenTagCount > 0 ? <span className="neo-tag">+{hiddenTagCount}</span> : null}
<Link
href={signal.sourceUrl}
target="_blank"
rel="noreferrer"
className="neo-btn inline-flex items-center gap-2 bg-white px-3 py-1.5 text-[11px] dark:bg-surface-dark"
>
{translations.viewSource}
<ArrowUpRight className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
<div
className={`absolute bottom-3 left-3 inline-flex items-center gap-1 border-2 border-black px-2 py-1 text-[10px] font-display font-bold uppercase ${sourceInfo.chipClass}`}
>
<SourceIcon className="h-3 w-3" aria-hidden="true" />
{sourceInfo.label}
</div>
</div>
</article>
)
})}
</div>
{hasMore ? (
<div className="flex justify-center">
<button
type="button"
onClick={() => {
void handleLoadMore()
}}
disabled={isLoadingMore}
className="neo-btn inline-flex items-center gap-2 bg-white px-4 py-2 text-xs disabled:opacity-60 dark:bg-surface-dark"
>
{isLoadingMore ? translations.loading : translations.loadMore}
</button>
</div>
) : null}
</div>
)}
</div>
)
}
+67
View File
@@ -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<typeof ChatModeEnum>;
export type ChatRole = z.infer<typeof ChatRoleEnum>;
export type ChatBlockType = z.infer<typeof ChatBlockTypeEnum>;
export type ChatEventType = z.infer<typeof ChatEventTypeEnum>;
export type SignalSource = z.infer<typeof SignalSourceEnum>;
export type SignalSectionStyle = z.infer<typeof SignalSectionStyleEnum>;
export type SignalSectionInput = z.infer<typeof SignalSectionSchema>;
export type SignalIngestionInput = z.infer<typeof SignalIngestionInputSchema>;
export type SignalWebhookPayload = z.infer<typeof SignalWebhookPayloadSchema>;
export type SignalQuery = z.infer<typeof SignalQuerySchema>;
export type ChatJobStatus = z.infer<typeof ChatJobStatusEnum>;
export type ChatFeedbackRating = z.infer<typeof ChatFeedbackRatingEnum>;
export type ChatCitation = z.infer<typeof ChatCitationSchema>;
+84
View File
@@ -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"
},
+84
View File
@@ -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": "提交项目"
},