@lobehub/chat
Version:
Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.
223 lines (186 loc) • 6.23 kB
text/typescript
import dayjs from 'dayjs';
import matter from 'gray-matter';
import { template } from 'lodash-es';
import { markdownToTxt } from 'markdown-to-txt';
import semver from 'semver';
import urlJoin from 'url-join';
import { FetchCacheTag } from '@/const/cacheControl';
import { Locales } from '@/locales/resources';
import { ChangelogIndexItem } from '@/types/changelog';
const URL_TEMPLATE = 'https://raw.githubusercontent.com/{{user}}/{{repo}}/{{branch}}/{{path}}';
const LAST_MODIFIED = new Date().toISOString();
const docCdnPrefix = process.env.DOC_S3_PUBLIC_DOMAIN || '';
export interface ChangelogConfig {
branch: string;
cdnPath: string;
changelogPath: string;
docsPath: string;
majorVersion: number;
repo: string;
type: 'cloud' | 'community';
urlTemplate: string;
user: string;
}
export class ChangelogService {
cdnUrls: {
[key: string]: string;
} = {};
config: ChangelogConfig = {
branch: process.env.DOCS_BRANCH || 'main',
cdnPath: 'docs/.cdn.cache.json',
changelogPath: 'changelog',
docsPath: 'docs/changelog',
majorVersion: 1,
repo: 'lobe-chat',
type: 'cloud',
urlTemplate: process.env.CHANGELOG_URL_TEMPLATE || URL_TEMPLATE,
user: 'lobehub',
};
async getLatestChangelogId() {
const index = await this.getChangelogIndex();
return index[0]?.id;
}
async getChangelogIndex(): Promise<ChangelogIndexItem[]> {
try {
const url = this.genUrl(urlJoin(this.config.docsPath, 'index.json'));
const res = await fetch(url, {
next: { revalidate: 3600, tags: [FetchCacheTag.Changelog] },
});
if (res.ok) {
const data = await res.json();
return this.mergeChangelogs(data.cloud, data.community).slice(0, 5);
}
return [];
} catch (e) {
const cause = (e as Error).cause as { code: string };
if (cause?.code.includes('ETIMEDOUT')) {
console.warn(
'[ChangelogFetchTimeout] fail to fetch changelog lists due to network timeout. Please check your network connection.',
);
} else {
console.error('Error getting changelog lists:', e);
}
return [];
}
}
async getIndexItemById(id: string) {
const index = await this.getChangelogIndex();
return index.find((item) => item.id === id);
}
async getPostById(id: string, options?: { locale?: Locales }) {
await this.cdnInit();
try {
const post = await this.getIndexItemById(id);
const filename = options?.locale?.startsWith('zh') ? `${id}.zh-CN.mdx` : `${id}.mdx`;
const url = this.genUrl(urlJoin(this.config.docsPath, filename));
const response = await fetch(url, {
next: { revalidate: 3600, tags: [FetchCacheTag.Changelog] },
});
const text = await response.text();
const { data, content } = matter(text);
const regex = /^#\s(.+)/;
const match = regex.exec(content.trim());
const matches = content.trim().split(regex);
let description: string;
if (matches[2]) {
description = matches[2] ? matches[2].trim() : '';
} else {
description = matches[1] ? matches[1].trim() : '';
}
if (docCdnPrefix) {
const images = this.extractHttpsLinks(content);
for (const url of images) {
const cdnUrl = this.replaceCdnUrl(url);
if (cdnUrl && url !== cdnUrl) {
description = description.replaceAll(url, cdnUrl);
}
}
}
return {
date: post?.date
? new Date(post.date)
: data?.date
? new Date(data.date)
: new Date(LAST_MODIFIED),
description: markdownToTxt(description.replaceAll('\n', '').replaceAll(' ', ' ')).slice(
0,
160,
),
image: post?.image ? this.replaceCdnUrl(post.image) : undefined,
tags: ['changelog'],
title: match ? match[1] : '',
...data,
content: description,
rawTitle: match ? match[1] : '',
};
} catch (e) {
console.error('[ChangelogFetchError]failed to fetch changlog post', id);
console.error(e);
return false as any;
}
}
private mergeChangelogs(
cloud: ChangelogIndexItem[],
community: ChangelogIndexItem[],
): ChangelogIndexItem[] {
if (this.config.type === 'community') {
return community;
}
const merged = [...community];
for (const cloudItem of cloud) {
const index = merged.findIndex((item) => item.id === cloudItem.id);
if (index !== -1) {
merged[index] = cloudItem;
} else {
merged.push(cloudItem);
}
}
return merged
.map((item) => ({
...item,
date: dayjs(item.date).format('YYYY-MM-DD'),
versionRange: this.formatVersionRange(item.versionRange),
}))
.sort((a, b) => semver.rcompare(a.versionRange[0], b.versionRange[0]));
}
private formatVersionRange(range: string[]): string[] {
if (range.length === 1) {
return range;
}
const [v1, v2]: any = range.map((v) => semver.parse(v)?.toString());
const minVersion = semver.lt(v1, v2) ? v1 : v2;
const maxVersion = semver.gt(v1, v2) ? v1 : v2;
return [minVersion, maxVersion];
}
private genUrl(path: string) {
// 自定义分隔符为 {{}}
const compiledTemplate = template(this.config.urlTemplate, { interpolate: /{{([\S\s]+?)}}/g });
return compiledTemplate({ ...this.config, path });
}
private extractHttpsLinks(text: string) {
const regex = /https:\/\/[^\s"')>]+/g;
const links = text.match(regex);
return links || [];
}
private async cdnInit() {
if (!docCdnPrefix) return;
if (Object.keys(this.cdnUrls).length === 0) {
try {
const url = this.genUrl(this.config.cdnPath);
const res = await fetch(url);
const data = await res.json();
if (data) {
this.cdnUrls = data;
}
} catch (error) {
console.error('Error getting changelog cdn cache:', error);
}
}
}
private replaceCdnUrl(url: string) {
if (!docCdnPrefix || !this.cdnUrls?.[url]) {
return url;
}
return urlJoin(docCdnPrefix, this.cdnUrls[url]);
}
}