feat: 新增前沿信号聚合页面与Webhook入库能力
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "提交项目"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user