diff --git a/docs/n8n/frontier-signals-workflow.md b/docs/n8n/frontier-signals-workflow.md index 86d9854..906dfc5 100644 --- a/docs/n8n/frontier-signals-workflow.md +++ b/docs/n8n/frontier-signals-workflow.md @@ -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) diff --git a/package.json b/package.json index f0703dd..9d655ba 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 581a190..fc60908 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") } diff --git a/scripts/backfill-signal-hotness.ts b/scripts/backfill-signal-hotness.ts new file mode 100644 index 0000000..faa3fa1 --- /dev/null +++ b/scripts/backfill-signal-hotness.ts @@ -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() + }) diff --git a/src/app/[locale]/signals/page.tsx b/src/app/[locale]/signals/page.tsx index 86c55bc..25e543b 100644 --- a/src/app/[locale]/signals/page.tsx +++ b/src/app/[locale]/signals/page.tsx @@ -21,17 +21,18 @@ export default async function SignalsPage({ params }: SignalsPageProps) { const t = await getTranslations({ locale, namespace: 'signals' }) return ( -
-
-
-
-

- {t('heroEyebrow')} -

-

- {t('heroTitle')} -

-

+

+
+
+
+

+ {t('heroEyebrow')} +

+

+ {t('heroTitle')} +

+
+

{t('heroDescription')}

@@ -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'), diff --git a/src/app/api/signals/route.ts b/src/app/api/signals/route.ts index 0686ce8..418efaa 100644 --- a/src/app/api/signals/route.ts +++ b/src/app/api/signals/route.ts @@ -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 diff --git a/src/app/api/webhook/signals/route.ts b/src/app/api/webhook/signals/route.ts index c4f1ce3..877699b 100644 --- a/src/app/api/webhook/signals/route.ts +++ b/src/app/api/webhook/signals/route.ts @@ -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) { diff --git a/src/components/signals/SignalFeedClient.tsx b/src/components/signals/SignalFeedClient.tsx index ef65fab..9e7840e 100644 --- a/src/components/signals/SignalFeedClient.tsx +++ b/src/components/signals/SignalFeedClient.tsx @@ -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('all') - const [sort, setSort] = useState('latest') + const [sort, setSort] = useState('hot') + const [isSortMenuOpen, setIsSortMenuOpen] = useState(false) + const sortMenuRef = useRef(null) const [signals, setSignals] = useState([]) const [nextCursor, setNextCursor] = useState(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 ( -
-
-
- - -
-
@@ -414,20 +509,25 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
{translations.noResults}
) : (
-
+
{signals.map((signal) => { const sourceInfo = sourceMeta[signal.source] const SourceIcon = sourceInfo.icon - const visibleTags = signal.tags.slice(0, 2) - const hiddenTagCount = Math.max(0, signal.tags.length - visibleTags.length) return (

{signal.title}

- - {formatDate(signal.publishedAt, locale)} - +
+ {signal.isHot ? ( + + {translations.hotBadge} + + ) : null} +
+ {formatDate(signal.publishedAt, locale)} +
+

@@ -442,29 +542,23 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps >

{section.title}

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

{item}

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

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

- ) : null}
))}
- {visibleTags.map((tag) => ( + {signal.tags.map((tag) => ( {tag} ))} - {hiddenTagCount > 0 ? +{hiddenTagCount} : null} = { + 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') +} diff --git a/src/lib/validations.ts b/src/lib/validations.ts index 18dc47b..c597e1b 100644 --- a/src/lib/validations.ts +++ b/src/lib/validations.ts @@ -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), }); diff --git a/src/messages/en.json b/src/messages/en.json index 8adce6c..506df27 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -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", diff --git a/src/messages/zh.json b/src/messages/zh.json index 83e0536..0c1f3cc 100644 --- a/src/messages/zh.json +++ b/src/messages/zh.json @@ -183,6 +183,9 @@ "discussionAnglesTitle": "争议与分歧", "sortLatest": "最新优先", "sortHot": "热度优先", + "hotBadge": "HOT", + "totalCountHint": "当前筛选条件下的讨论总数", + "hotCountHint": "当前筛选条件下的高热度讨论数量", "sourceAll": "全部来源", "sourceHackerNews": "Hacker News", "sourceGithub": "GitHub",