chore: checkpoint ai search comments and i18n foundation

This commit is contained in:
2026-03-28 17:17:31 +08:00
parent d18a709987
commit ec96d91548
71 changed files with 9494 additions and 423 deletions

View File

@@ -32,6 +32,10 @@ export interface Comment {
avatar: string | null;
content: string | null;
reply_to: string | null;
reply_to_comment_id: number | null;
scope: 'article' | 'paragraph';
paragraph_key: string | null;
paragraph_excerpt: string | null;
approved: boolean | null;
created_at: string;
updated_at: string;
@@ -42,7 +46,16 @@ export interface CreateCommentInput {
nickname: string;
email?: string;
content: string;
scope?: 'article' | 'paragraph';
paragraphKey?: string;
paragraphExcerpt?: string;
replyTo?: string | null;
replyToCommentId?: number | null;
}
export interface ParagraphCommentSummary {
paragraph_key: string;
count: number;
}
export interface ApiTag {
@@ -98,6 +111,23 @@ export interface ApiSiteSettings {
social_email: string | null;
location: string | null;
tech_stack: string[] | null;
ai_enabled: boolean;
}
export interface AiSource {
slug: string;
title: string;
excerpt: string;
score: number;
chunk_index: number;
}
export interface AiAskResponse {
question: string;
answer: string;
sources: AiSource[];
indexed_chunks: number;
last_indexed_at: string | null;
}
export interface ApiSearchResult {
@@ -153,6 +183,9 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
email: 'mailto:hello@termi.dev',
},
techStack: ['Astro', 'Svelte', 'Tailwind CSS', 'TypeScript'],
ai: {
enabled: false,
},
};
const formatPostDate = (dateString: string) => dateString.slice(0, 10);
@@ -244,6 +277,9 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
email: settings.social_email || DEFAULT_SITE_SETTINGS.social.email,
},
techStack: settings.tech_stack?.length ? settings.tech_stack : DEFAULT_SITE_SETTINGS.techStack,
ai: {
enabled: Boolean(settings.ai_enabled),
},
});
class ApiClient {
@@ -293,14 +329,32 @@ class ApiClient {
return posts.find(post => post.slug === slug) || null;
}
async getComments(postSlug: string, options?: { approved?: boolean }): Promise<Comment[]> {
async getComments(
postSlug: string,
options?: {
approved?: boolean;
scope?: 'article' | 'paragraph';
paragraphKey?: string;
}
): Promise<Comment[]> {
const params = new URLSearchParams({ post_slug: postSlug });
if (options?.approved !== undefined) {
params.set('approved', String(options.approved));
}
if (options?.scope) {
params.set('scope', options.scope);
}
if (options?.paragraphKey) {
params.set('paragraph_key', options.paragraphKey);
}
return this.fetch<Comment[]>(`/comments?${params.toString()}`);
}
async getParagraphCommentSummary(postSlug: string): Promise<ParagraphCommentSummary[]> {
const params = new URLSearchParams({ post_slug: postSlug });
return this.fetch<ParagraphCommentSummary[]>(`/comments/paragraphs/summary?${params.toString()}`);
}
async createComment(comment: CreateCommentInput): Promise<Comment> {
return this.fetch<Comment>('/comments', {
method: 'POST',
@@ -309,7 +363,11 @@ class ApiClient {
nickname: comment.nickname,
email: comment.email,
content: comment.content,
scope: comment.scope,
paragraphKey: comment.paragraphKey,
paragraphExcerpt: comment.paragraphExcerpt,
replyTo: comment.replyTo,
replyToCommentId: comment.replyToCommentId,
}),
});
}
@@ -398,6 +456,13 @@ class ApiClient {
})
);
}
async askAi(question: string): Promise<AiAskResponse> {
return this.fetch<AiAskResponse>('/ai/ask', {
method: 'POST',
body: JSON.stringify({ question }),
});
}
}
export const api = new ApiClient(API_BASE_URL);

View File

@@ -0,0 +1,144 @@
import { messages } from './messages';
export const SUPPORTED_LOCALES = ['zh-CN', 'en'] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'zh-CN';
export const LOCALE_COOKIE_NAME = 'termi_locale';
type TranslateParams = Record<string, string | number | null | undefined>;
function resolveMessage(locale: Locale, key: string): string | undefined {
const segments = key.split('.');
let current: unknown = messages[locale];
for (const segment of segments) {
if (!current || typeof current !== 'object' || !(segment in current)) {
current = undefined;
break;
}
current = (current as Record<string, unknown>)[segment];
}
return typeof current === 'string' ? current : undefined;
}
function interpolate(template: string, params?: TranslateParams): string {
if (!params) {
return template;
}
return template.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? ''));
}
export function normalizeLocale(value: string | null | undefined): Locale | null {
if (!value) {
return null;
}
const normalized = value.trim().toLowerCase();
if (!normalized) {
return null;
}
if (normalized.startsWith('zh')) {
return 'zh-CN';
}
if (normalized.startsWith('en')) {
return 'en';
}
return null;
}
export function resolveLocale(options: {
query?: string | null;
cookie?: string | null;
acceptLanguage?: string | null;
}): Locale {
const fromQuery = normalizeLocale(options.query);
if (fromQuery) {
return fromQuery;
}
const fromCookie = normalizeLocale(options.cookie);
if (fromCookie) {
return fromCookie;
}
const acceptLanguages = String(options.acceptLanguage || '')
.split(',')
.map((part) => normalizeLocale(part.split(';')[0]))
.filter(Boolean) as Locale[];
return acceptLanguages[0] || DEFAULT_LOCALE;
}
export function translate(locale: Locale, key: string, params?: TranslateParams): string {
const template =
resolveMessage(locale, key) ??
resolveMessage(DEFAULT_LOCALE, key) ??
key;
return interpolate(template, params);
}
export function getMessages(locale: Locale) {
return messages[locale];
}
export function buildLocaleUrl(url: URL, locale: Locale): string {
const next = new URL(url);
next.searchParams.set('lang', locale);
return `${next.pathname}${next.search}${next.hash}`;
}
export function getI18n(Astro: {
url: URL;
request: Request;
cookies?: {
get?: (name: string) => { value: string } | undefined;
};
}) {
const requestUrl = new URL(Astro.request.url);
const locale = resolveLocale({
query: requestUrl.searchParams.get('lang'),
cookie: Astro.cookies?.get?.(LOCALE_COOKIE_NAME)?.value ?? null,
acceptLanguage: Astro.request.headers.get('accept-language'),
});
const t = (key: string, params?: TranslateParams) => translate(locale, key, params);
return {
locale,
t,
messages: getMessages(locale),
buildLocaleUrl: (targetLocale: Locale) => buildLocaleUrl(requestUrl, targetLocale),
};
}
export function getReadTimeMinutes(value: string | number | null | undefined): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
const match = String(value || '').match(/\d+/);
if (!match) {
return null;
}
return Number(match[0]);
}
export function formatReadTime(
_locale: Locale,
value: string | number | null | undefined,
t: (key: string, params?: TranslateParams) => string
): string {
const minutes = getReadTimeMinutes(value);
if (minutes === null) {
return String(value || '');
}
return t('common.readTimeMinutes', { count: minutes });
}

View File

@@ -0,0 +1,696 @@
export const messages = {
'zh-CN': {
common: {
language: '语言',
languages: {
'zh-CN': '简体中文',
en: 'English',
},
all: '全部',
search: '搜索',
ai: 'AI',
article: '文章',
tweet: '动态',
posts: '文章',
tags: '标签',
categories: '分类',
friends: '友链',
location: '位置',
unknown: '未知',
other: '其他',
current: '当前',
readTime: '阅读时间',
readTimeMinutes: '{count} 分钟',
characters: '{count} 字',
postsCount: '{count} 篇',
tagsCount: '{count} 个标签',
categoriesCount: '{count} 个分类',
friendsCount: '{count} 个友链',
reviewsCount: '{count} 条评价',
resultsCount: '{count} 条结果',
reviewedOnly: '仅展示已通过审核',
noData: '暂无数据',
noResults: '没有匹配结果',
open: '打开',
close: '关闭',
submit: '提交',
cancel: '取消',
clear: '清除',
reset: '重置',
reply: '回复',
like: '点赞',
visit: '访问',
readMore: '阅读全文',
viewArticle: '打开文章',
viewAllArticles: '查看所有文章',
viewAllLinks: '查看全部友链',
viewCategoryArticles: '查看分类文章',
clearFilters: '清除筛选',
resetFilters: '重置筛选',
home: '首页',
browsePosts: '浏览文章',
goBack: '返回上一页',
backToIndex: '返回索引',
copyPermalink: '复制固定链接',
locateParagraph: '定位段落',
maxChars: '最多 {count} 字',
optional: '可选',
pending: '待审核',
approved: '已审核',
completed: '已完成',
inProgress: '进行中',
featureOn: '功能已开启',
featureOff: '功能未开启',
emptyState: '当前还没有内容。',
apiUnavailable: 'API 暂时不可用',
},
nav: {
articles: '文章',
categories: '分类',
tags: '标签',
timeline: '时间轴',
reviews: '评价',
friends: '友链',
about: '关于',
ask: 'AI 问答',
},
header: {
navigation: '导航',
themeToggle: '切换主题',
toggleMenu: '切换菜单',
searchModeKeyword: '搜索',
searchModeAi: 'AI',
searchModeKeywordMobile: '关键词搜索',
searchModeAiMobile: 'AI 搜索',
searchPlaceholderKeyword: "'关键词'",
searchPlaceholderAi: '输入问题,交给站内 AI',
searchHintKeyword: 'articles/*.md',
searchHintAi: '手动确认',
aiModeTitle: 'AI 问答模式',
aiModeHeading: '把这个问题交给站内 AI',
aiModeDescription: 'AI 会先检索站内知识库,再给出总结式回答,并附带相关文章来源。',
aiModeNotice: '进入问答页后不会自动调用模型,需要你手动确认发送。',
aiModeCta: '前往 AI 问答页确认',
liveResults: '实时搜索结果',
searching: '正在搜索 {query} ...',
searchFailed: '搜索失败,请稍后再试。',
searchEmpty: '没有找到和 {query} 相关的内容。',
searchEmptyCta: '去 AI 问答页确认提问',
searchAiFooter: '去 AI 问答页手动确认',
searchAllResults: '查看全部搜索结果',
untitled: '未命名',
manualConfirm: 'AI 必须手动确认后才会提问',
},
footer: {
session: '会话',
copyright: '© {year} {site}. 保留所有权利。',
sitemap: '站点地图',
rss: 'RSS 订阅',
},
home: {
pinned: '置顶',
about: '关于我',
techStack: '技术栈',
systemStatus: '系统状态',
},
articlesPage: {
title: '文章索引',
description: '按类型、分类和标签筛选内容,快速浏览整个内容目录。',
totalPosts: '共 {count} 篇',
allCategories: '全部分类',
allTags: '全部标签',
emptyTitle: '没有匹配结果',
emptyDescription: '当前筛选条件下没有找到文章。可以清空标签或关键字,重新浏览整个内容目录。',
pageSummary: '第 {current} / {total} 页 · 共 {count} 条结果',
previous: '上一页',
next: '下一页',
},
article: {
backToArticles: '返回文章索引',
documentSession: '文档会话',
filePath: '文件路径',
},
relatedPosts: {
title: '相关文章',
description: '基于当前分类与标签关联出的相近内容,延续同一条阅读链路。',
linked: '{count} 条关联',
},
comments: {
title: '评论终端',
description: '这里是整篇文章的讨论区,当前缓冲区共有 {count} 条已展示评论,新的留言提交后会进入审核队列。',
writeComment: '写评论',
nickname: '昵称',
email: '邮箱',
message: '内容',
messagePlaceholder: "$ echo '留下你的想法...'",
maxChars: '最多 500 字',
cancelReply: '取消回复',
emptyTitle: '暂无评论',
emptyDescription: '当前还没有留言。可以打开上面的输入面板,成为第一个在这个终端缓冲区里发言的人。',
today: '今天',
yesterday: '昨天',
daysAgo: '{count} 天前',
weeksAgo: '{count} 周前',
anonymous: '匿名',
submitting: '正在提交评论...',
submitSuccess: '评论已提交,审核通过后会显示在这里。',
submitFailed: '提交失败:{message}',
loadFailed: '加载评论失败',
noSelection: '当前没有选中的评论。',
},
paragraphComments: {
title: '段落评论已启用',
intro: '正文里的自然段都会挂一个轻量讨论入口,适合只针对某一段补充上下文、指出问题或继续展开讨论。',
scanning: '正在扫描段落缓冲区...',
noParagraphs: '当前文章没有可挂载评论的自然段。',
summary: '已为 {paragraphCount} 个自然段挂载评论入口,其中 {discussedCount} 段已有讨论,当前共展示 {approvedCount} 条已审核段落评论。',
focusCurrent: '聚焦当前段落',
panelTitle: '段落讨论面板',
close: '关闭',
nickname: '昵称',
email: '邮箱',
comment: '评论',
commentPlaceholder: "$ echo '只评论这一段...'",
maxChars: '最多 500 字',
clearReply: '清除回复',
replyTo: '回复给',
approvedThread: '公开讨论',
pendingQueue: '待审核队列',
emptyTitle: '这段还没有公开评论',
emptyDescription: '适合补充上下文、指出细节或者提出具体问题。新的留言提交后会先进入审核队列。',
loadingThread: '正在拉取该段的已审核评论...',
loadFailedShort: '加载失败',
loadFailed: '加载失败:{message}',
selectedRequired: '当前没有选中的段落。',
contextMissing: '段落上下文丢失,请重新打开该段评论面板。',
submitting: '正在提交段落评论...',
submitSuccess: '评论已提交,已先放入本地待审核队列;审核通过后会进入公开讨论。',
submitFailed: '提交失败:{message}',
anonymous: '匿名',
oneNote: '1 条评论',
manyNotes: '{count} 条评论',
zeroNotes: '评论',
waitingReview: '等待审核',
locateParagraph: '定位段落',
},
ask: {
pageTitle: 'AI 问答',
pageDescription: '基于 {siteName} 内容知识库的站内 AI 问答',
title: 'AI 站内问答',
subtitle: '基于博客 Markdown 内容建立索引,回答会优先引用站内真实资料。',
disabledTitle: '后台暂未开启 AI 问答',
disabledDescription: '这个入口已经接好了真实后端,但当前站点设置里没有开启公开问答。管理员开启后,这里会自动变成可用状态,导航也会同步显示。',
textareaPlaceholder: '输入你想问的问题,比如:这个博客关于前端写过哪些内容?',
submit: '开始提问',
idleStatus: '知识库已接入,等待问题输入。',
examples: '示例问题',
workflow: '工作流',
workflow1: '1. 后台开启 AI 开关并配置聊天模型。',
workflow2: '2. 重建索引,把 Markdown 文章切块后由后端本地生成 embedding并写入 PostgreSQL pgvector。',
workflow3: '3. 前台提问时先在 pgvector 中做相似度检索,再交给聊天模型基于上下文回答。',
emptyAnswer: '暂无回答。',
requestFailed: '请求失败:{message}',
streamUnsupported: '当前浏览器无法读取流式响应。',
enterQuestion: '先输入一个问题。',
cacheRestored: '已从当前会话缓存中恢复回答。',
connecting: '正在建立流式连接,请稍候...',
processing: '正在处理请求...',
complete: '回答已生成。',
streamFailed: '流式请求失败',
streamInterrupted: '流式响应被提前中断。',
retryLater: '这次请求没有成功,可以稍后重试。',
prefixedQuestion: '已带入搜索词,确认后开始提问。',
sources: '来源',
},
about: {
pageTitle: '关于',
title: '关于我',
intro: '这里汇总站点主人、技术栈、系统状态和联系方式,并与全站语言设置保持一致。',
techStackCount: '{count} 项技术栈',
profile: '身份档案',
contact: '联系方式',
website: '网站',
},
categories: {
pageTitle: '分类',
title: '文章分类',
intro: '按内容主题浏览文章,分类页现在和其他列表页保持同一套终端面板语言。',
quickJump: '快速跳转分类文章',
categoryPosts: '浏览 {name} 主题下的全部文章和更新记录。',
empty: '暂无分类数据',
},
friends: {
pageTitle: '友情链接',
pageDescription: '与 {siteName} 交换友情链接',
title: '友情链接',
intro: '这里聚合已经通过审核的站点,也提供统一风格的申请面板,避免列表区和表单区像两个页面。',
collection: '友链分组',
exchangeRules: '友链交换',
exchangeIntro: '欢迎交换友情链接,请确保您的网站满足以下条件:',
rule1: '原创内容为主',
rule2: '网站稳定运行',
rule3: '无不良内容',
siteInfo: '本站信息:',
name: '名称',
description: '描述',
link: '链接',
},
friendForm: {
title: '提交友链申请',
intro: '填写站点信息后会提交到后台审核,审核通过后前台会自动展示。',
reviewedOnline: '后台审核后上线',
siteName: '站点名称',
siteUrl: '站点链接',
avatarUrl: '头像链接',
category: '分类',
description: '站点描述',
reciprocal: '已添加本站友链',
reciprocalHint: '这是提交申请前的必要条件。',
copy: '复制',
copied: '已复制',
submit: '提交申请',
reset: '重置',
addReciprocalFirst: '请先添加本站友链后再提交申请。',
submitting: '正在提交友链申请...',
submitSuccess: '友链申请已提交,我们会尽快审核。',
submitFailed: '提交失败:{message}',
categoryTech: '技术',
categoryLife: '生活',
categoryDesign: '设计',
categoryOther: '其他',
descriptionPlaceholder: '简要介绍一下你的网站...',
},
friendCard: {
externalLink: '外部链接',
},
tags: {
pageTitle: '标签',
title: '标签云',
intro: '用更轻量的关键词维度检索文章。选中标签时,下方结果区会延续同一套终端卡片风格。',
currentTag: '当前: #{tag}',
selectedSummary: '标签 #{tag} 找到 {count} 篇文章',
browseTags: '浏览标签',
emptyTags: '暂无标签数据',
emptyPosts: '没有找到该标签的文章',
},
timeline: {
pageTitle: '时间轴',
pageDescription: '记录 {ownerName} 的技术成长与生活点滴',
title: '时间轴',
subtitle: '共 {count} 篇内容 · 记录 {ownerName} 的技术成长与生活点滴',
allYears: '全部',
},
reviews: {
pageTitle: '评价',
pageDescription: '记录游戏、音乐、动画、书籍与影视的体验和评价',
title: '评价',
subtitle: '记录游戏、音乐、动画、书籍与影视的体验和感悟',
total: '总评价',
average: '平均评分',
completed: '已完成',
inProgress: '进行中',
emptyData: '暂无评价数据,请检查后端 API 连接',
emptyFiltered: '当前筛选下暂无评价',
currentFilter: '当前筛选: {type}',
typeAll: '全部',
typeGame: '游戏',
typeAnime: '动画',
typeMusic: '音乐',
typeBook: '书籍',
typeMovie: '影视',
},
notFound: {
pageTitle: '页面未找到',
pageDescription: '您访问的页面不存在',
title: '404 - 页面未找到',
intro: '当前请求没有命中任何内容节点。下面保留了终端化错误信息、可执行操作,以及可回退到的真实文章入口。',
terminalLog: '终端错误日志',
requestedRouteNotFound: '错误:请求的路由不存在',
path: '路径',
time: '时间',
actions: '可执行操作',
actionsIntro: '像命令面板一样,优先给出直接可走的恢复路径。',
searchHint: '也可以直接使用顶部的搜索输入框,在 `articles/*.md` 里重新 grep 一次相关关键字。',
recommended: '推荐入口',
recommendedIntro: '使用真实文章数据,避免 404 页面再把人带进不存在的地址。',
cannotLoad: '暂时无法读取文章列表。',
},
toc: {
title: '目录',
intro: '实时跟踪当前文档的标题节点,像终端侧栏一样快速跳转。',
},
codeCopy: {
copy: '复制',
copied: '已复制',
failed: '失败',
},
},
en: {
common: {
language: 'Language',
languages: {
'zh-CN': '简体中文',
en: 'English',
},
all: 'All',
search: 'Search',
ai: 'AI',
article: 'Article',
tweet: 'Update',
posts: 'Posts',
tags: 'Tags',
categories: 'Categories',
friends: 'Links',
location: 'Location',
unknown: 'Unknown',
other: 'Other',
current: 'Current',
readTime: 'Read time',
readTimeMinutes: '{count} min read',
characters: '{count} chars',
postsCount: '{count} posts',
tagsCount: '{count} tags',
categoriesCount: '{count} categories',
friendsCount: '{count} links',
reviewsCount: '{count} reviews',
resultsCount: '{count} results',
reviewedOnly: 'Approved links only',
noData: 'No data yet',
noResults: 'No matching results',
open: 'Open',
close: 'Close',
submit: 'Submit',
cancel: 'Cancel',
clear: 'Clear',
reset: 'Reset',
reply: 'Reply',
like: 'Like',
visit: 'Visit',
readMore: 'Read more',
viewArticle: 'Open article',
viewAllArticles: 'View all articles',
viewAllLinks: 'View all links',
viewCategoryArticles: 'View category posts',
clearFilters: 'Clear filters',
resetFilters: 'Reset filters',
home: 'Home',
browsePosts: 'Browse posts',
goBack: 'Go back',
backToIndex: 'Back to index',
copyPermalink: 'Copy permalink',
locateParagraph: 'Locate paragraph',
maxChars: 'Max {count} chars',
optional: 'Optional',
pending: 'Pending',
approved: 'Approved',
completed: 'Completed',
inProgress: 'In progress',
featureOn: 'Feature on',
featureOff: 'Feature off',
emptyState: 'Nothing here yet.',
apiUnavailable: 'API temporarily unavailable',
},
nav: {
articles: 'Articles',
categories: 'Categories',
tags: 'Tags',
timeline: 'Timeline',
reviews: 'Reviews',
friends: 'Links',
about: 'About',
ask: 'Ask AI',
},
header: {
navigation: 'Navigation',
themeToggle: 'Toggle theme',
toggleMenu: 'Toggle menu',
searchModeKeyword: 'Search',
searchModeAi: 'AI',
searchModeKeywordMobile: 'Keyword Search',
searchModeAiMobile: 'AI Search',
searchPlaceholderKeyword: "'keyword'",
searchPlaceholderAi: 'Type a question for the site AI',
searchHintKeyword: 'articles/*.md',
searchHintAi: 'manual confirm',
aiModeTitle: 'AI Q&A mode',
aiModeHeading: 'Send this question to the site AI',
aiModeDescription: 'The AI will search the site knowledge base first, then answer with source-backed summaries.',
aiModeNotice: 'The model will not run automatically after navigation. You must confirm manually.',
aiModeCta: 'Open AI Q&A to confirm',
liveResults: 'Live results',
searching: 'Searching {query} ...',
searchFailed: 'Search failed. Please try again later.',
searchEmpty: 'No content matched {query}.',
searchEmptyCta: 'Ask AI instead',
searchAiFooter: 'Open AI Q&A manually',
searchAllResults: 'View all results',
untitled: 'Untitled',
manualConfirm: 'AI questions must be confirmed manually',
},
footer: {
session: 'Session',
copyright: '© {year} {site}. All rights reserved.',
sitemap: 'Sitemap',
rss: 'RSS feed',
},
home: {
pinned: 'Pinned',
about: 'About',
techStack: 'Tech stack',
systemStatus: 'System status',
},
articlesPage: {
title: 'Article Index',
description: 'Filter content by type, category, and tag to browse the full archive quickly.',
totalPosts: '{count} posts',
allCategories: 'All categories',
allTags: 'All tags',
emptyTitle: 'No matching results',
emptyDescription: 'No posts matched the current filters. Clear a tag or keyword to browse the full archive again.',
pageSummary: 'Page {current}/{total} · {count} results',
previous: 'Prev',
next: 'Next',
},
article: {
backToArticles: 'Back to article index',
documentSession: 'Document session',
filePath: 'File path',
},
relatedPosts: {
title: 'Related Posts',
description: 'More nearby reading paths based on the current category and shared tags.',
linked: '{count} linked',
},
comments: {
title: 'Comment Terminal',
description: 'This is the discussion thread for the whole article. {count} approved comments are shown right now, and new messages enter moderation first.',
writeComment: 'Write comment',
nickname: 'Nickname',
email: 'Email',
message: 'Message',
messagePlaceholder: "$ echo 'Leave your thoughts here...'",
maxChars: 'Max 500 chars',
cancelReply: 'Cancel reply',
emptyTitle: 'No comments yet',
emptyDescription: 'No one has posted here yet. Open the input panel above and be the first voice in this buffer.',
today: 'Today',
yesterday: 'Yesterday',
daysAgo: '{count} days ago',
weeksAgo: '{count} weeks ago',
anonymous: 'Anonymous',
submitting: 'Submitting comment...',
submitSuccess: 'Comment submitted. It will appear here after moderation.',
submitFailed: 'Submit failed: {message}',
loadFailed: 'Failed to load comments',
noSelection: 'No comment is selected.',
},
paragraphComments: {
title: 'Paragraph comments are enabled',
intro: 'Each natural paragraph in the article gets a lightweight discussion entry point, perfect for focused context, corrections, or follow-up questions.',
scanning: 'Scanning paragraph buffer...',
noParagraphs: 'No commentable paragraphs were found in this article.',
summary: '{paragraphCount} paragraphs have comment entries, {discussedCount} already have discussion, and {approvedCount} approved paragraph comments are currently visible.',
focusCurrent: 'Focus current paragraph',
panelTitle: 'Paragraph discussion panel',
close: 'Close',
nickname: 'Nickname',
email: 'Email',
comment: 'Comment',
commentPlaceholder: "$ echo 'Comment on this paragraph only...'",
maxChars: 'Max 500 chars',
clearReply: 'Clear reply',
replyTo: 'Reply to',
approvedThread: 'Approved thread',
pendingQueue: 'Pending queue',
emptyTitle: 'No public comments on this paragraph yet',
emptyDescription: 'Use this space to add context, point out details, or ask a specific question. New comments go through moderation first.',
loadingThread: 'Loading approved comments for this paragraph...',
loadFailedShort: 'Load failed',
loadFailed: 'Load failed: {message}',
selectedRequired: 'No paragraph is currently selected.',
contextMissing: 'Paragraph context was lost. Please reopen the paragraph panel.',
submitting: 'Submitting paragraph comment...',
submitSuccess: 'Comment submitted. It has been placed into the local pending queue and will join the public thread after moderation.',
submitFailed: 'Submit failed: {message}',
anonymous: 'Anonymous',
oneNote: '1 note',
manyNotes: '{count} notes',
zeroNotes: 'comment',
waitingReview: 'waiting review',
locateParagraph: 'Locate paragraph',
},
ask: {
pageTitle: 'Ask AI',
pageDescription: 'An on-site AI Q&A experience grounded in the {siteName} knowledge base',
title: 'On-site AI Q&A',
subtitle: 'Answers are grounded in indexed Markdown content from the blog and prioritize real on-site references.',
disabledTitle: 'AI Q&A is not enabled yet',
disabledDescription: 'The real backend integration is already in place, but public Q&A is still disabled in site settings. Once it is enabled, this page and the navigation entry will become available automatically.',
textareaPlaceholder: 'Ask anything, for example: what has this blog written about frontend topics?',
submit: 'Ask now',
idleStatus: 'Knowledge base connected. Waiting for a question.',
examples: 'Example questions',
workflow: 'Workflow',
workflow1: '1. Enable the AI switch in the admin and configure the chat model.',
workflow2: '2. Rebuild the index so Markdown content is chunked, embedded locally by the backend, and written into PostgreSQL pgvector.',
workflow3: '3. Each user question retrieves similar chunks from pgvector first, then the chat model answers with that context.',
emptyAnswer: 'No answer yet.',
requestFailed: 'Request failed: {message}',
streamUnsupported: 'This browser cannot read streaming responses.',
enterQuestion: 'Enter a question first.',
cacheRestored: 'Restored the answer from the current session cache.',
connecting: 'Opening stream connection...',
processing: 'Processing request...',
complete: 'Answer generated.',
streamFailed: 'Streaming request failed',
streamInterrupted: 'The streaming response ended early.',
retryLater: 'This request did not complete successfully. Please try again later.',
prefixedQuestion: 'The search query has been prefilled. Confirm manually to ask AI.',
sources: 'Sources',
},
about: {
pageTitle: 'About',
title: 'About',
intro: 'This page gathers the site owner profile, tech stack, system stats, and contact details while following the same language setting as the rest of the site.',
techStackCount: '{count} tech items',
profile: 'Profile',
contact: 'Contact',
website: 'Website',
},
categories: {
pageTitle: 'Categories',
title: 'Categories',
intro: 'Browse posts by topic. This page now follows the same terminal language as the other list views.',
quickJump: 'Jump straight into category posts',
categoryPosts: 'Browse all posts and updates under {name}.',
empty: 'No category data yet',
},
friends: {
pageTitle: 'Links',
pageDescription: 'Exchange links with {siteName}',
title: 'Friend Links',
intro: 'This page gathers approved sites and keeps the application panel in the same visual language so the list and form feel like one screen.',
collection: 'Friend collection',
exchangeRules: 'Link exchange',
exchangeIntro: 'You are welcome to exchange links. Please make sure your site meets these conditions:',
rule1: 'Original content as the main focus',
rule2: 'Stable uptime',
rule3: 'No harmful content',
siteInfo: 'Site info:',
name: 'Name',
description: 'Description',
link: 'Link',
},
friendForm: {
title: 'Submit a link request',
intro: 'Fill in your site info and it will be sent to the moderation queue. Once approved, it will appear on the frontend automatically.',
reviewedOnline: 'Published after review',
siteName: 'Site name',
siteUrl: 'Site URL',
avatarUrl: 'Avatar URL',
category: 'Category',
description: 'Site description',
reciprocal: 'I already added this site to my links',
reciprocalHint: 'This is required before submission.',
copy: 'Copy',
copied: 'Copied',
submit: 'Submit request',
reset: 'Reset',
addReciprocalFirst: 'Please add this site to your links before submitting.',
submitting: 'Submitting link request...',
submitSuccess: 'Link request submitted. We will review it soon.',
submitFailed: 'Submit failed: {message}',
categoryTech: 'Tech',
categoryLife: 'Life',
categoryDesign: 'Design',
categoryOther: 'Other',
descriptionPlaceholder: 'Briefly describe your site...',
},
friendCard: {
externalLink: 'External link',
},
tags: {
pageTitle: 'Tags',
title: 'Tag Cloud',
intro: 'Browse posts through lightweight keyword slices. When a tag is selected, the result area keeps the same terminal card language.',
currentTag: 'Current: #{tag}',
selectedSummary: 'Tag #{tag} matched {count} posts',
browseTags: 'Browse tags',
emptyTags: 'No tag data yet',
emptyPosts: 'No posts found for this tag',
},
timeline: {
pageTitle: 'Timeline',
pageDescription: 'A timeline of {ownerName}\'s technical growth and life notes',
title: 'Timeline',
subtitle: '{count} entries · tracing {ownerName}\'s technical growth and life notes',
allYears: 'All',
},
reviews: {
pageTitle: 'Reviews',
pageDescription: 'Notes and ratings for games, music, anime, books, and films',
title: 'Reviews',
subtitle: 'Tracking thoughts on games, music, anime, books, and films',
total: 'Total reviews',
average: 'Average rating',
completed: 'Completed',
inProgress: 'In progress',
emptyData: 'No review data yet. Please check the backend API connection.',
emptyFiltered: 'No reviews match the current filter',
currentFilter: 'Current filter: {type}',
typeAll: 'All',
typeGame: 'Games',
typeAnime: 'Anime',
typeMusic: 'Music',
typeBook: 'Books',
typeMovie: 'Films',
},
notFound: {
pageTitle: 'Page not found',
pageDescription: 'The page you requested does not exist',
title: '404 - Page not found',
intro: 'The current request did not resolve to any content node. This page keeps the terminal-style error output, direct recovery actions, and real fallback articles.',
terminalLog: 'Terminal error log',
requestedRouteNotFound: 'error: requested route not found',
path: 'path',
time: 'time',
actions: 'Actions',
actionsIntro: 'Like a command palette, this page surfaces the most direct recovery paths first.',
searchHint: 'You can also use the search box in the header and grep through `articles/*.md` again.',
recommended: 'Recommended entries',
recommendedIntro: 'These use real article data so the 404 page does not send people into more dead ends.',
cannotLoad: 'Unable to load the article list right now.',
},
toc: {
title: 'Contents',
intro: 'Track document headings in real time and jump around like a terminal side panel.',
},
codeCopy: {
copy: 'Copy',
copied: 'Copied',
failed: 'Failed',
},
},
} as const;
export type MessageCatalog = typeof messages;

View File

@@ -61,6 +61,9 @@ export interface SiteSettings {
email?: string;
};
techStack: string[];
ai: {
enabled: boolean;
};
}
export interface SiteConfig {

View File

@@ -92,3 +92,10 @@ export function filterPosts(
export function getPostTypeColor(type: string): string {
return type === 'article' ? 'var(--primary)' : 'var(--secondary)';
}
export {
buildParagraphDescriptors,
createParagraphExcerpt,
fnv1aHash,
normalizeParagraphText,
} from './paragraph-comments';

View File

@@ -0,0 +1,57 @@
export interface ParagraphDescriptor {
element: HTMLParagraphElement;
key: string;
excerpt: string;
normalizedText: string;
}
export function normalizeParagraphText(text: string): string {
return text.replace(/\s+/g, ' ').trim().toLowerCase();
}
export function createParagraphExcerpt(text: string, limit = 120): string {
const flattened = text.replace(/\s+/g, ' ').trim();
if (flattened.length <= limit) {
return flattened;
}
return `${flattened.slice(0, limit).trimEnd()}...`;
}
export function fnv1aHash(value: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16).padStart(8, '0');
}
export function buildParagraphDescriptors(container: HTMLElement): ParagraphDescriptor[] {
const occurrences = new Map<string, number>();
const paragraphs = Array.from(container.querySelectorAll('p')).filter(
child => child instanceof HTMLParagraphElement
) as HTMLParagraphElement[];
return paragraphs
.map(element => {
const normalizedText = normalizeParagraphText(element.textContent || '');
if (!normalizedText) {
return null;
}
const hash = fnv1aHash(normalizedText);
const nextOccurrence = (occurrences.get(hash) || 0) + 1;
occurrences.set(hash, nextOccurrence);
return {
element,
key: `p-${hash}-${nextOccurrence}`,
excerpt: createParagraphExcerpt(element.textContent || ''),
normalizedText,
};
})
.filter((item): item is ParagraphDescriptor => Boolean(item));
}