feat: 完善 signals 热度筛选与展示交互
This commit is contained in:
@@ -17,7 +17,7 @@ Aggregate frontier discussions from multiple platforms, keep only **AI Agent-rel
|
||||
| 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` |
|
||||
| GitHub | `https://api.github.com/search/issues` | HTTP Request | `title`, `html_url`, `body`, `comments`, `reactions`, `labels`, `updated_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` |
|
||||
@@ -39,7 +39,7 @@ Aggregate frontier discussions from multiple platforms, keep only **AI Agent-rel
|
||||
|
||||
The workflow emits payload compatible with `SignalWebhookPayloadSchema`:
|
||||
|
||||
- `apiKey`: from `$env.WEBHOOK_API_KEY`
|
||||
- `apiKey`: manually configured in node `构建 Webhook Payload`
|
||||
- `signals[]`:
|
||||
- `source` -> one of:
|
||||
- `hacker_news`
|
||||
@@ -48,7 +48,7 @@ The workflow emits payload compatible with `SignalWebhookPayloadSchema`:
|
||||
- `hugging_face`
|
||||
- `reddit`
|
||||
- `product_hunt`
|
||||
- `sourceUrl`, `title`, `summary`, `topic`, `tags`, `sections`, `engagement`, `publishedAt`, `isActive`
|
||||
- `sourceUrl`, `title`, `summary`, `topic`, `tags`, `sections`, `engagement`, `hotScore`, `isHot`, `publishedAt`, `isActive`
|
||||
|
||||
Sections are constrained to:
|
||||
|
||||
@@ -62,7 +62,49 @@ It only decides whether a signal is about AI Agent topics and then structures co
|
||||
|
||||
- Keep (`shouldKeep=true`) if discussion is materially agent-related
|
||||
- Drop (`shouldKeep=false`) if clearly unrelated to AI Agent
|
||||
- Ambiguous cases bias to keep
|
||||
- Drop low-discussion or low-value question items (`shouldKeep=false`)
|
||||
- If information is insufficient, default to drop
|
||||
|
||||
## High-Heat Gate(AI 前硬过滤)
|
||||
|
||||
`筛掉占位候选` 节点会在 AI 前做硬性筛选,减少低价值输入:
|
||||
|
||||
- source-level minimum engagement:
|
||||
- `hacker_news >= 35`
|
||||
- `github >= 8`
|
||||
- `reddit >= 25`
|
||||
- source-level freshness window:
|
||||
- `hacker_news <= 72h`
|
||||
- `github <= 168h`
|
||||
- `reddit/arxiv/hugging_face/product_hunt <= 120h`
|
||||
- low-value question filtering:
|
||||
- 求助型单问题 + 短摘要 + 低互动,直接丢弃
|
||||
- per-source candidate cap(AI 前限流):
|
||||
- `hacker_news 6`, `github 6`, `reddit 6`, `arxiv 4`, `hugging_face 4`, `product_hunt 4`
|
||||
- strict keyword gate for low-discussion sources:
|
||||
- `arxiv/hugging_face/product_hunt` 必须命中 agent 相关关键词(如 `agent`, `agentic`, `multi-agent`, `tool calling`, `mcp`, `智能体`)才进入 AI
|
||||
|
||||
## HOT 判定(流程内)
|
||||
|
||||
`清洗并映射入库字段` Code 节点会基于**互动量 + 时间新鲜度**计算:
|
||||
|
||||
- `hotScore`:0-100
|
||||
- `isHot`:布尔值,用于页面 HOT 标签与排序优先
|
||||
|
||||
该判定只衡量讨论热度,不评价观点对错或质量。
|
||||
|
||||
## DB 升级(新增 HOT 字段)
|
||||
|
||||
由于当前仓库默认忽略 `prisma/migrations/*_*` 目录,建议在数据库手动执行一次:
|
||||
|
||||
```sql
|
||||
ALTER TABLE "signals"
|
||||
ADD COLUMN IF NOT EXISTS "hotScore" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS "isHot" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_signal_hot_score" ON "signals"("hotScore");
|
||||
CREATE INDEX IF NOT EXISTS "idx_signal_active_hot_sort" ON "signals"("isActive", "isHot", "hotScore", "publishedAt");
|
||||
```
|
||||
|
||||
## Manual Configuration (No Env)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"taxonomy:backfill": "ts-node --compiler-options '{\"module\":\"CommonJS\",\"moduleResolution\":\"node\"}' scripts/backfill-tag-taxonomy.ts",
|
||||
"signals:backfill-hot": "ts-node --compiler-options '{\"module\":\"CommonJS\",\"moduleResolution\":\"node\"}' scripts/backfill-signal-hotness.ts",
|
||||
"domains:sync": "ts-node --compiler-options '{\"module\":\"CommonJS\",\"moduleResolution\":\"node\"}' scripts/sync-domain-scenarios.ts",
|
||||
"tags:dedupe-free": "ts-node --compiler-options '{\"module\":\"CommonJS\",\"moduleResolution\":\"node\"}' scripts/dedupe-free-tags-to-canonical.ts"
|
||||
},
|
||||
|
||||
@@ -113,6 +113,8 @@ model Signal {
|
||||
tags Json
|
||||
sections Json
|
||||
engagement Int @default(0)
|
||||
hotScore Int @default(0)
|
||||
isHot Boolean @default(false)
|
||||
publishedAt DateTime
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
@@ -121,7 +123,9 @@ model Signal {
|
||||
@@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([isActive, isHot, hotScore, publishedAt], map: "idx_signal_active_hot_sort")
|
||||
@@index([engagement], map: "idx_signal_engagement")
|
||||
@@index([hotScore], map: "idx_signal_hot_score")
|
||||
@@map("signals")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { computeSignalHotness } from '../src/lib/signal-hotness'
|
||||
import type { SignalSource } from '../src/lib/validations'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
function isSignalSource(value: string): value is SignalSource {
|
||||
return (
|
||||
value === 'hacker_news' ||
|
||||
value === 'github' ||
|
||||
value === 'arxiv' ||
|
||||
value === 'hugging_face' ||
|
||||
value === 'reddit' ||
|
||||
value === 'product_hunt'
|
||||
)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('[signals-hot] start backfill')
|
||||
|
||||
const rows = await prisma.signal.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
source: true,
|
||||
engagement: true,
|
||||
publishedAt: true,
|
||||
hotScore: true,
|
||||
isHot: true,
|
||||
},
|
||||
})
|
||||
|
||||
let updated = 0
|
||||
let skipped = 0
|
||||
|
||||
for (const row of rows) {
|
||||
if (!isSignalSource(row.source)) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const next = computeSignalHotness({
|
||||
source: row.source,
|
||||
engagement: row.engagement,
|
||||
publishedAt: row.publishedAt,
|
||||
})
|
||||
|
||||
if (row.hotScore === next.hotScore && row.isHot === next.isHot) {
|
||||
continue
|
||||
}
|
||||
|
||||
await prisma.signal.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
hotScore: next.hotScore,
|
||||
isHot: next.isHot,
|
||||
},
|
||||
})
|
||||
|
||||
updated += 1
|
||||
}
|
||||
|
||||
console.log('[signals-hot] done', {
|
||||
total: rows.length,
|
||||
updated,
|
||||
skippedUnknownSource: skipped,
|
||||
})
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error('[signals-hot] failed', error)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@@ -21,17 +21,18 @@ export default async function SignalsPage({ params }: SignalsPageProps) {
|
||||
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">
|
||||
<main className="container mx-auto max-w-7xl px-4 py-6 md:py-8">
|
||||
<section className="neo-card mb-4 p-4 md:p-5 relative overflow-hidden">
|
||||
<div className="absolute -right-4 -top-4 h-16 w-16 rotate-12 border-2 border-black opacity-15 dark:border-gray-500" />
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<p className="inline-flex items-center gap-2 border-2 border-black bg-primary px-2.5 py-1.5 font-display text-[10px] font-bold uppercase text-black shadow-neo-sm">
|
||||
{t('heroEyebrow')}
|
||||
</p>
|
||||
<h1 className="font-display text-2xl font-bold leading-tight tracking-tight md:text-3xl">
|
||||
{t('heroTitle')}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-relaxed text-gray-700 dark:text-gray-300 md:text-base">
|
||||
{t('heroDescription')}
|
||||
</p>
|
||||
</section>
|
||||
@@ -54,6 +55,9 @@ export default async function SignalsPage({ params }: SignalsPageProps) {
|
||||
sourceHuggingFace: t('sourceHuggingFace'),
|
||||
sourceReddit: t('sourceReddit'),
|
||||
sourceProductHunt: t('sourceProductHunt'),
|
||||
hotBadge: t('hotBadge'),
|
||||
totalCountHint: t('totalCountHint'),
|
||||
hotCountHint: t('hotCountHint'),
|
||||
loadMore: t('loadMore'),
|
||||
loading: t('loading'),
|
||||
loadFailed: t('loadFailed'),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { ZodError } from 'zod'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { computeSignalHotness, isSignalHotColumnMissingError } from '@/lib/signal-hotness'
|
||||
import {
|
||||
SignalQuerySchema,
|
||||
type SignalQuery,
|
||||
@@ -24,6 +25,8 @@ interface SignalView {
|
||||
source: SignalSource
|
||||
sourceUrl: string
|
||||
engagement: number
|
||||
hotScore: number
|
||||
isHot: boolean
|
||||
publishedAt: string
|
||||
topic: string
|
||||
tags: string[]
|
||||
@@ -143,6 +146,8 @@ function toSignalView(row: {
|
||||
tags: Prisma.JsonValue
|
||||
sections: Prisma.JsonValue
|
||||
engagement: number
|
||||
hotScore?: number | null
|
||||
isHot?: boolean | null
|
||||
publishedAt: Date
|
||||
}, locale: SignalQuery['locale']): SignalView | null {
|
||||
if (!isSignalSource(row.source)) {
|
||||
@@ -151,6 +156,13 @@ function toSignalView(row: {
|
||||
|
||||
const title = pickLocalizedText(locale, row.title, row.titleEn)
|
||||
const summary = pickLocalizedText(locale, row.summary, row.summaryEn)
|
||||
const fallbackHotness = computeSignalHotness({
|
||||
source: row.source,
|
||||
engagement: row.engagement,
|
||||
publishedAt: row.publishedAt,
|
||||
})
|
||||
const hotScore = typeof row.hotScore === 'number' && row.hotScore > 0 ? row.hotScore : fallbackHotness.hotScore
|
||||
const isHot = typeof row.isHot === 'boolean' ? row.isHot || fallbackHotness.isHot : fallbackHotness.isHot
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -160,6 +172,8 @@ function toSignalView(row: {
|
||||
source: row.source,
|
||||
sourceUrl: row.sourceUrl,
|
||||
engagement: row.engagement,
|
||||
hotScore,
|
||||
isHot,
|
||||
publishedAt: row.publishedAt.toISOString(),
|
||||
topic: pickLocalizedText(locale, row.topic || '', row.topicEn || undefined),
|
||||
tags: parseTags(row.tags),
|
||||
@@ -215,35 +229,88 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const orderBy: Prisma.SignalOrderByWithRelationInput[] =
|
||||
parsed.sort === 'hot'
|
||||
? [{ engagement: 'desc' }, { publishedAt: 'desc' }, { id: 'desc' }]
|
||||
? [{ isHot: 'desc' }, { hotScore: 'desc' }, { 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 cursorArgs = parsed.cursor
|
||||
? {
|
||||
cursor: { id: parsed.cursor },
|
||||
skip: 1 as const,
|
||||
}
|
||||
: {}
|
||||
|
||||
let rows: Array<{
|
||||
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
|
||||
hotScore?: number | null
|
||||
isHot?: boolean | null
|
||||
publishedAt: Date
|
||||
}>
|
||||
|
||||
try {
|
||||
rows = await prisma.signal.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
take: parsed.limit + 1,
|
||||
...cursorArgs,
|
||||
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,
|
||||
hotScore: true,
|
||||
isHot: true,
|
||||
publishedAt: true,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (!isSignalHotColumnMissingError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const fallbackOrderBy: Prisma.SignalOrderByWithRelationInput[] =
|
||||
parsed.sort === 'hot' ? [{ engagement: 'desc' }, { publishedAt: 'desc' }, { id: 'desc' }] : [{ publishedAt: 'desc' }, { id: 'desc' }]
|
||||
|
||||
rows = await prisma.signal.findMany({
|
||||
where,
|
||||
orderBy: fallbackOrderBy,
|
||||
take: parsed.limit + 1,
|
||||
...cursorArgs,
|
||||
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
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { isValidApiKey } from '@/lib/auth'
|
||||
import { computeSignalHotness, isSignalHotColumnMissingError } from '@/lib/signal-hotness'
|
||||
import {
|
||||
SignalIngestionInputSchema,
|
||||
SignalWebhookPayloadSchema,
|
||||
@@ -84,6 +85,27 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const validSignal = itemValidation.data as SignalIngestionInput
|
||||
const computedHotness = computeSignalHotness({
|
||||
source: validSignal.source,
|
||||
engagement: validSignal.engagement,
|
||||
publishedAt: validSignal.publishedAt,
|
||||
})
|
||||
const hotScore =
|
||||
typeof validSignal.hotScore === 'number' ? validSignal.hotScore : computedHotness.hotScore
|
||||
const isHot = typeof validSignal.isHot === 'boolean' ? validSignal.isHot : computedHotness.isHot
|
||||
const baseData = {
|
||||
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,
|
||||
}
|
||||
|
||||
const existing = await prisma.signal.findUnique({
|
||||
where: {
|
||||
@@ -103,33 +125,36 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
},
|
||||
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,
|
||||
...baseData,
|
||||
hotScore,
|
||||
isHot,
|
||||
},
|
||||
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,
|
||||
...baseData,
|
||||
hotScore,
|
||||
isHot,
|
||||
},
|
||||
}).catch(async (error) => {
|
||||
if (!isSignalHotColumnMissingError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
await prisma.signal.upsert({
|
||||
where: {
|
||||
source_sourceUrl: {
|
||||
source: validSignal.source,
|
||||
sourceUrl: validSignal.sourceUrl,
|
||||
},
|
||||
},
|
||||
update: baseData,
|
||||
create: {
|
||||
source: validSignal.source,
|
||||
sourceUrl: validSignal.sourceUrl,
|
||||
...baseData,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
Flame,
|
||||
FlaskConical,
|
||||
Globe2,
|
||||
Github,
|
||||
List,
|
||||
MessageCircle,
|
||||
Newspaper,
|
||||
Rocket,
|
||||
@@ -41,6 +44,8 @@ export interface IdeaSignal {
|
||||
source: SignalSource
|
||||
sourceUrl: string
|
||||
engagement: number
|
||||
hotScore: number
|
||||
isHot: boolean
|
||||
publishedAt: string
|
||||
topic: string
|
||||
tags: string[]
|
||||
@@ -76,6 +81,9 @@ interface SignalFeedClientProps {
|
||||
sourceHuggingFace: string
|
||||
sourceReddit: string
|
||||
sourceProductHunt: string
|
||||
hotBadge: string
|
||||
totalCountHint: string
|
||||
hotCountHint: string
|
||||
loadMore: string
|
||||
loading: string
|
||||
loadFailed: string
|
||||
@@ -216,7 +224,9 @@ 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 [sort, setSort] = useState<SortKey>('hot')
|
||||
const [isSortMenuOpen, setIsSortMenuOpen] = useState(false)
|
||||
const sortMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const [signals, setSignals] = useState<IdeaSignal[]>([])
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||
@@ -283,6 +293,35 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
||||
}
|
||||
}, [debouncedSearch, locale, sort, source])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSortMenuOpen) {
|
||||
return
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!sortMenuRef.current) {
|
||||
return
|
||||
}
|
||||
if (event.target instanceof Node && !sortMenuRef.current.contains(event.target)) {
|
||||
setIsSortMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
setIsSortMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('pointerdown', handlePointerDown)
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', handlePointerDown)
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isSortMenuOpen])
|
||||
|
||||
async function handleLoadMore() {
|
||||
if (!hasMore || !nextCursor || isLoadingMore) {
|
||||
return
|
||||
@@ -344,64 +383,120 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
||||
'data-[active=true]:bg-black data-[active=true]:text-white dark:data-[active=true]:bg-primary dark:data-[active=true]:text-black',
|
||||
}
|
||||
|
||||
const activeSortLabel = sortOptions.find((option) => option.key === sort)?.label || translations.sortLatest
|
||||
const hotCount = signals.filter((item) => item.isHot).length
|
||||
|
||||
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}
|
||||
<div className="space-y-3">
|
||||
<article className="neo-card bg-white/95 p-2.5 backdrop-blur dark:bg-surface-dark/95 md:p-3">
|
||||
<div className="border-2 border-black bg-orange-50/70 p-2 dark:border-gray-600 dark:bg-gray-900/50 md:p-2.5">
|
||||
<div className="grid grid-cols-1 gap-2 xl:grid-cols-[minmax(0,1fr)_220px_auto]">
|
||||
<label className="flex h-10 items-center gap-2.5 border-2 border-black bg-white px-3 dark:border-gray-500 dark:bg-surface-dark">
|
||||
<Search className="h-4 w-4 shrink-0" 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>
|
||||
<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
|
||||
<div ref={sortMenuRef} className="relative">
|
||||
<button
|
||||
id="signal-sort"
|
||||
type="button"
|
||||
aria-label={translations.sortLabel}
|
||||
aria-expanded={isSortMenuOpen}
|
||||
aria-haspopup="listbox"
|
||||
onClick={() => setIsSortMenuOpen((previous) => !previous)}
|
||||
className="neo-btn flex h-10 w-full items-center justify-between bg-white px-2.5 dark:bg-surface-dark"
|
||||
>
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 font-display text-xs font-bold uppercase text-black dark:text-gray-100">
|
||||
{sort === 'hot' ? <TrendingUp className="h-3.5 w-3.5 shrink-0" aria-hidden="true" /> : null}
|
||||
<span className="truncate">{activeSortLabel}</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 shrink-0 transition-transform ${isSortMenuOpen ? 'rotate-180' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
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}`}
|
||||
{isSortMenuOpen ? (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-labelledby="signal-sort"
|
||||
className="neo-card absolute left-0 right-0 top-[calc(100%+6px)] z-30 overflow-hidden"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<SourceIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{sourceLabels[option]}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{sortOptions.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={sort === option.key}
|
||||
onClick={() => {
|
||||
setSort(option.key)
|
||||
setIsSortMenuOpen(false)
|
||||
}}
|
||||
className={`flex w-full items-center border-b-2 border-black px-2.5 py-2 text-left font-display text-xs font-bold uppercase last:border-b-0 dark:border-gray-600 ${
|
||||
sort === option.key
|
||||
? 'bg-primary text-black'
|
||||
: 'bg-white text-black hover:bg-gray-100 dark:bg-surface-dark dark:text-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{option.key === 'hot' ? <TrendingUp className="h-3.5 w-3.5 shrink-0" aria-hidden="true" /> : null}
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-start gap-1.5 xl:justify-end">
|
||||
<span
|
||||
title={translations.totalCountHint}
|
||||
aria-label={`${translations.totalCountHint}: ${signals.length}`}
|
||||
className="inline-flex h-8 items-center gap-1 border-2 border-black bg-primary px-2 py-1 font-display text-[10px] font-bold uppercase text-black"
|
||||
>
|
||||
<List className="h-3 w-3" aria-hidden="true" />
|
||||
{signals.length}
|
||||
</span>
|
||||
<span
|
||||
title={translations.hotCountHint}
|
||||
aria-label={`${translations.hotCountHint}: ${hotCount}`}
|
||||
className="inline-flex h-8 items-center gap-1 border-2 border-black bg-red-200 px-2 py-1 font-display text-[10px] font-bold uppercase text-black"
|
||||
>
|
||||
<Flame className="h-3 w-3" aria-hidden="true" />
|
||||
{hotCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 min-w-0 overflow-x-auto pb-1">
|
||||
<div className="flex min-w-max gap-1.5">
|
||||
{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={`h-8 border-2 border-black bg-white px-2.5 font-display text-[11px] 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>
|
||||
</div>
|
||||
</article>
|
||||
@@ -414,20 +509,25 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
||||
<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">
|
||||
<div className="grid grid-cols-1 gap-2.5">
|
||||
{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 className="shrink-0 text-right">
|
||||
{signal.isHot ? (
|
||||
<span className="inline-flex border-2 border-black bg-red-300 px-1.5 py-0.5 font-display text-[10px] font-bold uppercase text-black">
|
||||
{translations.hotBadge}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="mt-1 font-display text-xs font-bold uppercase text-gray-500 dark:text-gray-400">
|
||||
{formatDate(signal.publishedAt, locale)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-1.5 line-clamp-3 text-sm leading-relaxed text-gray-700 dark:text-gray-300">
|
||||
@@ -442,29 +542,23 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
||||
>
|
||||
<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) => (
|
||||
{section.items.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) => (
|
||||
{signal.tags.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"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { SignalSource } from '@/lib/validations'
|
||||
import { Prisma } from '@prisma/client'
|
||||
|
||||
interface HotnessInput {
|
||||
source: SignalSource
|
||||
engagement: number
|
||||
publishedAt: Date | string
|
||||
}
|
||||
|
||||
interface HotnessResult {
|
||||
hotScore: number
|
||||
isHot: boolean
|
||||
}
|
||||
|
||||
interface HotConfig {
|
||||
engagementBaseline: number
|
||||
minEngagement: number
|
||||
threshold: number
|
||||
}
|
||||
|
||||
const HOT_CONFIG: Record<SignalSource, HotConfig> = {
|
||||
hacker_news: { engagementBaseline: 140, minEngagement: 45, threshold: 64 },
|
||||
github: { engagementBaseline: 1200, minEngagement: 120, threshold: 66 },
|
||||
arxiv: { engagementBaseline: 20, minEngagement: 12, threshold: 72 },
|
||||
hugging_face: { engagementBaseline: 55, minEngagement: 20, threshold: 68 },
|
||||
reddit: { engagementBaseline: 160, minEngagement: 50, threshold: 65 },
|
||||
product_hunt: { engagementBaseline: 120, minEngagement: 35, threshold: 65 },
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function normalizeEngagement(engagement: number, baseline: number): number {
|
||||
if (engagement <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const capped = Math.min(engagement, baseline * 3)
|
||||
const score = (Math.log1p(capped) / Math.log1p(baseline)) * 100
|
||||
return clamp(score, 0, 100)
|
||||
}
|
||||
|
||||
function recencyScore(publishedAt: Date | string): number {
|
||||
const date = publishedAt instanceof Date ? publishedAt : new Date(publishedAt)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const ageHours = (Date.now() - date.getTime()) / (1000 * 60 * 60)
|
||||
if (ageHours <= 0) {
|
||||
return 100
|
||||
}
|
||||
|
||||
const score = 100 - ageHours * 2.4
|
||||
return clamp(score, 0, 100)
|
||||
}
|
||||
|
||||
export function computeSignalHotness(input: HotnessInput): HotnessResult {
|
||||
const safeEngagement = Number.isFinite(input.engagement) && input.engagement > 0 ? Math.floor(input.engagement) : 0
|
||||
const config = HOT_CONFIG[input.source]
|
||||
|
||||
const engagementPart = normalizeEngagement(safeEngagement, config.engagementBaseline)
|
||||
const recencyPart = recencyScore(input.publishedAt)
|
||||
const hotScore = Math.round(engagementPart * 0.72 + recencyPart * 0.28)
|
||||
|
||||
const isHot = safeEngagement >= config.minEngagement && hotScore >= config.threshold
|
||||
|
||||
return {
|
||||
hotScore: clamp(hotScore, 0, 100),
|
||||
isHot,
|
||||
}
|
||||
}
|
||||
|
||||
export function isSignalHotColumnMissingError(error: unknown): boolean {
|
||||
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (error.code !== 'P2022') {
|
||||
return false
|
||||
}
|
||||
|
||||
const column = String(error.meta?.column || '')
|
||||
return column.includes('hotScore') || column.includes('isHot')
|
||||
}
|
||||
@@ -197,6 +197,8 @@ export const SignalIngestionInputSchema = z.object({
|
||||
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),
|
||||
hotScore: z.coerce.number().int().min(0).max(100).optional(),
|
||||
isHot: z.boolean().optional(),
|
||||
publishedAt: z.string().datetime({ offset: true }),
|
||||
isActive: z.boolean().default(true),
|
||||
});
|
||||
|
||||
@@ -183,6 +183,9 @@
|
||||
"discussionAnglesTitle": "Disagreements",
|
||||
"sortLatest": "Latest First",
|
||||
"sortHot": "Most Discussed",
|
||||
"hotBadge": "HOT",
|
||||
"totalCountHint": "Total discussions under current filters",
|
||||
"hotCountHint": "High-heat discussions under current filters",
|
||||
"sourceAll": "All Sources",
|
||||
"sourceHackerNews": "Hacker News",
|
||||
"sourceGithub": "GitHub",
|
||||
|
||||
@@ -183,6 +183,9 @@
|
||||
"discussionAnglesTitle": "争议与分歧",
|
||||
"sortLatest": "最新优先",
|
||||
"sortHot": "热度优先",
|
||||
"hotBadge": "HOT",
|
||||
"totalCountHint": "当前筛选条件下的讨论总数",
|
||||
"hotCountHint": "当前筛选条件下的高热度讨论数量",
|
||||
"sourceAll": "全部来源",
|
||||
"sourceHackerNews": "Hacker News",
|
||||
"sourceGithub": "GitHub",
|
||||
|
||||
Reference in New Issue
Block a user